iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 15

Day 15 — 名詞中英對照頁:讓內容自己長出頁面

  • 分享至 

  • xImage
  •  

今天要解的問題

統計名詞的中英對照是學生的實際痛點:課本是英文、老師講中文、考試兩種都有。「型一誤差」和「Type I error」要能互相對上。

我需要一頁完整的名詞表。但這裡有兩條路,選錯會後悔:

做法 問題
手寫一份 glossary.js 兩百多課的名詞要手抄,而且課文改了名詞表不會跟著改——遲早不一致
從課文自動抽取 一次寫好腳本,永遠同步

Day 8 定的寫作規格裡,名詞是這樣寫的:

<dl class="terms">
  <dt>型一誤差 (Type I error)</dt><dd>H₀ 為真卻拒絕它。</dd>
  <dt>檢定力 (power)</dt><dd>H₁ 為真時正確拒絕 H₀ 的機率,等於 1−β。</dd>
</dl>

這個格式當初是為了排版一致,今天它變成可程式化的資產。這是規格化內容的複利。

抽取腳本

/* scripts/build-glossary.js */
const fs = require("fs"), path = require("path"), vm = require("vm");

const root = path.resolve(__dirname, "..");
const ctx = { window: {} }; vm.createContext(ctx);
vm.runInContext(fs.readFileSync(path.join(root, "js/models/curriculum.js"), "utf8"), ctx);
for (const f of fs.readdirSync(path.join(root, "js/lessons")).sort())
  vm.runInContext(fs.readFileSync(path.join(root, "js/lessons", f), "utf8"), ctx);
const LESSONS = ctx.window.LESSONS;
const COURSES = vm.runInContext("COURSES", ctx);

/* 課文 key → { 課程, 章, 單元 } 的反查表 */
const META = {};
for (const c of COURSES)
  for (const m of c.modules)
    for (const ch of m.chapters)
      for (const l of ch.lessons)
        META[`${ch.id}-${l.id}`] = { courseId: c.id, courseTitle: c.title,
                                     chId: ch.id, chTitle: `${ch.num} ${ch.title}`,
                                     lessonId: l.id, lessonTitle: l.title };

const unesc = s => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
                    .replace(/&amp;/g, "&").replace(/&nbsp;/g, " ");
const plain = s => unesc(s.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();

/* 「中文 (English)」→ 拆成兩半;沒括號的就只有中文 */
const splitTerm = dt => {
  const m = dt.match(/^(.*?)[((]\s*([^(())]*[A-Za-z][^(())]*)\s*[))]\s*$/);
  return m ? { zh: m[1].trim(), en: m[2].trim() } : { zh: dt.trim(), en: "" };
};

const terms = new Map();          // key: zh|en → { zh, en, defs:[], seen:[] }

for (const [key, html] of Object.entries(LESSONS)) {
  /* 逐個 <dl class="terms"> 區塊,抓成對的 dt/dd */
  for (const dl of html.matchAll(/<dl class="terms">([\s\S]*?)<\/dl>/g)) {
    const pairs = [...dl[1].matchAll(/<dt>([\s\S]*?)<\/dt>\s*<dd>([\s\S]*?)<\/dd>/g)];
    for (const [, rawDt, rawDd] of pairs) {
      const { zh, en } = splitTerm(plain(rawDt));
      if (!zh) continue;
      const id = `${zh}|${en.toLowerCase()}`;
      if (!terms.has(id)) terms.set(id, { zh, en, defs: [], seen: [] });
      const t = terms.get(id);
      const def = plain(rawDd);
      if (def && !t.defs.includes(def)) t.defs.push(def);
      t.seen.push({ key, ...META[key] });
    }
  }
}

幾個實作決定:

用 regex 而不是 DOM parser。 Node 沒有內建 DOM,裝 jsdom 又違反零依賴原則。而課文的 <dl class="terms"> 是我自己按規格寫的、格式穩定,regex 完全夠用。(如果是解析任意來源的 HTML,regex 就是錯的選擇——但這裡的輸入是我自己控制的。)

同一名詞的多個定義都保留。 「變異數」在敘述統計和變異數分析兩章都會出現,定義角度不同。硬要合併成一個定義會損失資訊;並列呈現反而讓使用者看到概念在不同脈絡下的面貌。

seen 是反向索引。 這是整個頁面最有價值的部分:不只告訴你「型一誤差是什麼」,還告訴你「它在第 9 章第 2 課、第 10 章第 1 課出現過」,一鍵跳過去看上下文。

中文排序:不能用 localeCompare 就算了

英文名詞按 A–Z 很簡單。中文要按什麼排?

/* 三種索引:英文首字母、中文注音、中文筆畫 */
const enInitial = t => (t.en ? t.en[0].toUpperCase() : "#");

/* 中文用 Intl.Collator 的 zh-Hant 排序(實際上是筆畫/字典序) */
const collator = new Intl.Collator("zh-Hant", { sensitivity: "base" });
const sorted = [...terms.values()].sort((a, b) => collator.compare(a.zh, b.zh));

Intl.Collator("zh-Hant") 在 Node 與現代瀏覽器都可用(需要完整 ICU,Node 14+ 的官方 build 有)。它給的是繁中的字典序,比 String.prototype.localeCompare 沒指定 locale 時的 code point 排序合理得多。

Code point 排序有多糟:「一」是 U+4E00、「乙」是 U+4E59,但「二」是 U+4E8C——按 code point,「一乙二」剛好對,可是「大」(U+5927) 會排在「三」(U+4E09) 後面很遠的地方,完全不是使用者預期。

不過老實說,中文名詞表最實用的索引是搜尋框,不是字母索引。所以我的頁面設計是:搜尋框放最上面(即時過濾),字母索引當輔助。

輸出資料檔

const out = {
  terms: sorted.map(t => ({
    zh: t.zh, en: t.en, defs: t.defs,
    seen: t.seen.map(s => ({ k: s.key, ch: s.chId, l: s.lessonId,
                             c: s.courseTitle, cht: s.chTitle, lt: s.lessonTitle })),
  })),
  byInitial: {},
};
for (const t of out.terms) {
  const k = enInitial(t);
  (out.byInitial[k] = out.byInitial[k] || []).push(t.zh);
}

fs.writeFileSync(path.join(root, "js/data/glossary.js"),
  `/* 自動產生,勿手改:node scripts/build-glossary.js */\nwindow.GLOSSARY = ${JSON.stringify(out)};`);

console.log(`terms=${out.terms.length}  有英文=${out.terms.filter(t=>t.en).length}  ` +
            `總出現次數=${out.terms.reduce((s,t)=>s+t.seen.length,0)}`);

老規矩:輸出 window.GLOSSARY = {...} 的 JS 檔而不是 JSON,因為 file:// 不能 fetch(Day 6 起的一貫理由)。

實測輸出:約 400 個名詞、其中約 310 個有英文對照、總出現次數約 700 次。檔案 120 KB,比搜尋索引小很多,可以直接在 glossary 頁載入。

頁面

/* js/views/glossary-view.js */
const GlossaryView = {
  render(root, { q = "", initial = null } = {}) {
    const all = (window.GLOSSARY || { terms: [] }).terms;
    const qq = q.trim().toLowerCase();

    const list = all.filter(t => {
      if (initial && (t.en ? t.en[0].toUpperCase() : "#") !== initial) return false;
      if (!qq) return true;
      return t.zh.toLowerCase().includes(qq) || t.en.toLowerCase().includes(qq)
          || t.defs.some(d => d.toLowerCase().includes(qq));
    });

    root.innerHTML = `
      <div class="gl-bar">
        <input id="gl-q" type="search" placeholder="搜尋名詞(中文或英文)" value="${esc(q)}"
               aria-label="搜尋名詞">
        <div class="gl-index">
          <button class="gl-i ${!initial ? "on" : ""}" data-i="">全部</button>
          ${"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map(c =>
            `<button class="gl-i ${initial === c ? "on" : ""}" data-i="${c}"
               ${(window.GLOSSARY.byInitial[c] || []).length ? "" : "disabled"}>${c}</button>`).join("")}
        </div>
        <div class="gl-count">${list.length} / ${all.length} 個名詞</div>
      </div>
      <dl class="gl-list">
        ${list.map(t => this.termHTML(t, qq)).join("") || `<p class="gl-empty">找不到相關名詞。</p>`}
      </dl>`;
  },

  termHTML(t, qq) {
    return `
    <div class="gl-term" id="term-${slug(t.zh)}">
      <dt>
        <span class="gl-zh">${mark(t.zh, qq)}</span>
        ${t.en ? `<span class="gl-en">${mark(t.en, qq)}</span>` : ""}
      </dt>
      <dd>
        ${t.defs.map(d => `<p class="gl-def">${mark(d, qq)}</p>`).join("")}
        <div class="gl-seen">
          出現在:
          ${t.seen.map(s =>
            `<a href="chapter.html?ch=${s.ch}&l=${s.l}" title="${esc(s.c)} · ${esc(s.cht)}">${esc(s.lt)}</a>`
          ).join("、")}
        </div>
      </dd>
    </div>`;
  },
};

esc()mark() 沿用 Day 12 的實作——使用者輸入(q)要進 innerHTML,一律跳脫。這是本系列第三次遇到同一件事(Day 9 localStorage、Day 12 搜尋、今天 glossary),已經變成肌肉記憶。

字母索引按鈕在沒有該字首名詞時 disabled,避免點了得到空清單。

反向連結的加值:課文也加上連結

有了 glossary 之後,可以反過來讓課文裡的名詞連到解釋。但不要自動全文替換——那是個陷阱:

// ❌ 危險:會把公式、程式碼、其他名詞的一部分也換掉
html.replace(/變異數/g, '<a href="glossary.html#term-變異數">變異數</a>');

問題:「變異數分析」裡的「變異數」會被切開、\text{變異數} 在 LaTeX 裡會被插入 HTML 標籤導致公式壞掉(Day 7 的教訓:課文裡亂動 HTML 會讓公式靜靜地不渲染)。

安全的做法是只處理 <dt> 本身——名詞表裡的詞連到 glossary 的對應條目:

/* 在 LessonView.renderLesson 之後執行,只碰 dt,不碰課文其他部分 */
linkTerms(body) {
  body.querySelectorAll("dl.terms > dt").forEach(dt => {
    const zh = (dt.textContent.match(/^([^((]+)/) || [])[1];
    if (!zh) return;
    dt.insertAdjacentHTML("beforeend",
      ` <a class="gl-link" href="glossary.html#term-${encodeURIComponent(slug(zh.trim()))}"
           title="在名詞表中查看">🔗</a>`);
  });
}

在 DOM 上操作而不是在 HTML 字串上替換,範圍精確可控。而且這段在 MathJax 排版之後執行,不會干擾公式(dl.terms 裡本來就不放公式)。

踩到的雷

splitTerm 的括號有全形也有半形。 我寫課文時中英混用,有時打 型一誤差 (Type I error)(半形括號),有時打 型一誤差(Type I error)(全形)。第一版 regex 只處理半形,漏掉三成名詞。

/^(.*?)[((]\s*([^(())]*[A-Za-z][^(())]*)\s*[))]\s*$/

字元類 [((][))] 同時接受兩種。另外要求括號內必須含至少一個英文字母[A-Za-z]),否則「信賴區間(雙尾)」的「雙尾」會被誤認成英文對照。

同名不同義的 key 設計。 我用 zh|en 當 key,這意味著「常態分配 (normal distribution)」和「常態分配 (Normal distribution)」會被視為不同名詞(大小寫)。所以 key 裡的 en 要 toLowerCase()。反過來,如果同一個中文詞有兩個不同英文(真的發生過:「檢定力 (power)」與「檢定力 (statistical power)」),我選擇保留為兩筆——那反映了課文的真實用詞不一致,正好可以順手去統一課文。

自動產生的檔案要標記。 檔頭寫 /* 自動產生,勿手改 */,並加進一個檢查:如果 glossary.js 比任何課文檔舊,CI 就警告。

# 簡易做法:CI 裡重跑腳本,然後看 git diff 是否為空
node scripts/build-glossary.js
git diff --exit-code js/data/glossary.js || { echo "glossary 過期,請重跑腳本並 commit"; exit 1; }

這招(重跑產生器 + git diff --exit-code)通用於所有自動產生的檔案,Day 22 的 CI 會把它變成標準步驟。

驗證

node scripts/build-glossary.js
# terms=402  有英文=311  總出現次數=713
node scripts/verify.js
python3 -m http.server 8901
# http://localhost:8901/glossary.html

檢查清單:

  • 搜「type」→ 英文命中(型一誤差、型二誤差…)。
  • 搜「誤差」→ 中文命中。
  • 搜「拒絕」→ 定義內文命中。
  • 字母索引 T → 只顯示英文以 T 開頭的。
  • 每個名詞的「出現在:」連結真的能跳到對應課文。
  • 課文頁的 dl.terms 每個 <dt> 後面有 🔗,點了跳到 glossary 並定位到該條目(#term-... anchor)。
  • 搜一段 script 標籤包 alert(1) 的 XSS 字串 → 顯示為純文字,不執行。

小結與明天預告

今天的核心觀念只有一句:

規格化的內容會變成可程式化的資產。

Day 8 我定 <dl class="terms"><dt>中文 (English)</dt> 只是為了排版一致。七天後,同一個格式免費長出一整頁 glossary 加反向索引。這種複利只有在一開始就把內容寫成結構化資料時才會發生——如果當初我用 <p><b>型一誤差</b>:…</p>,今天就抽不出來。

另外兩個可複用的技巧:自動產生的檔案用「重跑 + git diff --exit-code」防過期,以及要修改課文 DOM 就在 DOM 上做,不要在 HTML 字串上做正則替換

明天做離線化——把 MathJax 從 CDN 搬到本地、加上 service worker,讓網站斷網也能讀。順便把 Day 10 留下的 CSP 例外收乾淨(script-src 可以只剩 'self')。


上一篇
Day 14 — 章末總測驗:從一題 check-in 到多題計分
下一篇
Day 16 — 徹底離線:自架 MathJax + PWA
系列文
知識圖譜 : 技能樹式學習歷程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言