兩百多課的內容,我自己都找不到東西。「信賴區間的公式在哪一課?」現在只能靠記憶點進地圖再點課程再翻 sidebar。
需要搜尋。限制:
file:// 跑(不能 fetch 索引檔)。Lunr.js 是最常見的答案,但對中文有個根本問題:它的 tokenizer 是按空白切詞。
lunr.tokenizer("信賴區間的計算公式")
// → ["信賴區間的計算公式"] 整句變成一個 token
中文沒有空白。整句變一個 token 意味著只有輸入完全一樣的字串才會命中,搜「信賴區間」找不到「信賴區間的計算公式」。
要修就得自訂 tokenizer——那我還不如自己寫整個索引,反正核心邏輯只有幾十行,還能省掉一個依賴。(Lunr + 中文分詞函式庫 gzip 後大約 30 KB 起跳,而我自己的索引器是 0 KB 執行期程式碼,只有一個資料檔。)
三個選項:
| 方式 | 說明 | 適用性 |
|---|---|---|
| 詞典分詞(jieba) | 準確,但要載入幾 MB 詞典 | 太重 |
| bigram(雙字切分) | 「信賴區間」→ 信賴、賴區、區間 | ✅ |
| 單字(unigram) | 「信」「賴」「區」「間」 | 索引爆炸、雜訊太多 |
bigram 的原理很簡單:任何 ≥2 字的中文查詢,都能被切成一串相鄰雙字。只要索引裡有這些雙字的位置,就能用「全部 bigram 都命中」來判斷是不是子字串。
const bigrams = s => {
const out = [];
for (let i = 0; i < s.length - 1; i++) out.push(s.slice(i, i + 2));
return out;
};
bigrams("信賴區間"); // ["信賴", "賴區", "區間"]
「信賴區間」的三個 bigram 全部出現在同一課 → 那課極可能真的含這個詞。這不是精確比對(「區間信賴」也會命中兩個 bigram),但搭配最後一步的原文驗證就完全準確——見下面的排序邏輯。
英文與數字則照常按空白/邊界切詞,兩種混用:
const tokenize = text => {
const tokens = [];
/* 英數詞:長度 ≥ 2,轉小寫 */
for (const m of text.matchAll(/[a-zA-Z][a-zA-Z0-9]{1,}/g)) tokens.push(m[0].toLowerCase());
/* 中文(含全形符號濾掉):連續 CJK 段落各自做 bigram */
for (const m of text.matchAll(/[一-鿿]+/g)) tokens.push(...bigrams(m[0]));
return tokens;
};
索引是建置期產出,不是執行期計算。這樣執行期零成本。
/* scripts/build-search-index.js */
const fs = require("fs"), path = require("path"), vm = require("vm");
/* 老朋友:用 vm 把瀏覽器 script 當資料讀(Day 3) */
const ctx = { window: {} }; vm.createContext(ctx);
const root = path.resolve(__dirname, "..");
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 COURSES = vm.runInContext("COURSES", ctx);
const LESSONS = ctx.window.LESSONS;
/* HTML → 純文字。順手把公式與程式碼區塊整段丟掉:
LaTeX 原始碼當關鍵字搜不到東西,只會污染索引 */
const strip = html => html
.replace(/<div class="formula">[\s\S]*?<\/div>/g, " ")
.replace(/\\\([\s\S]*?\\\)/g, " ")
.replace(/<[^>]*>/g, " ")
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")
.replace(/\s+/g, " ").trim();
/* 文件清單:每課一筆 */
const docs = [];
for (const course of COURSES)
for (const m of course.modules)
for (const ch of m.chapters)
for (const l of ch.lessons) {
const key = `${ch.id}-${l.id}`;
docs.push({
k: key, // 課文 key
t: l.title, // 單元標題
c: course.id, cn: course.title, // 所屬課程
ch: ch.id, chn: `${ch.num} ${ch.title}`, // 所屬章
x: strip(LESSONS[key] || "").slice(0, 3000), // 內文(截斷,控制索引大小)
});
}
/* 倒排索引:token → [文件索引…](去重) */
const inv = {};
docs.forEach((d, i) => {
/* 標題權重高:重複計入,讓標題命中排前面 */
const text = `${d.t} ${d.t} ${d.chn} ${d.cn} ${d.x}`;
for (const tk of new Set(tokenize(text))) (inv[tk] = inv[tk] || []).push(i);
});
const out = `/* 自動產生,勿手改:node scripts/build-search-index.js */
window.SEARCH_INDEX = ${JSON.stringify({ docs, inv })};`;
fs.writeFileSync(path.join(root, "js/data/search-index.js"), out);
console.log(`docs=${docs.length} tokens=${Object.keys(inv).length} size=${(out.length/1024).toFixed(0)}KB`);
三個關鍵決定:
window.SEARCH_INDEX = {...} 的 JS 檔,不是 JSON。因為 file:// 不能 fetch(Day 6 同一個理由)。script src 永遠可以。\frac{\sigma}{\sqrt{n}} 進索引只會產生 frac、sigma、sqrt 這種雜訊 token,沒人會這樣搜。實測輸出約 900 KB(未壓縮),gzip 後約 250 KB。可接受,但不能在每一頁都載入。
/* js/views/search-view.js */
const Search = {
query(q) {
const idx = window.SEARCH_INDEX;
if (!idx || !q || q.trim().length < 2) return [];
const qq = q.trim().toLowerCase();
const tokens = tokenize(qq);
if (!tokens.length) return [];
/* 1. 交集:所有 token 都命中的文件才算候選 */
let cand = null;
for (const tk of new Set(tokens)) {
const posting = idx.inv[tk];
if (!posting) return []; // 任一 token 找不到 → 無結果
const s = new Set(posting);
cand = cand ? new Set([...cand].filter(i => s.has(i))) : s;
if (!cand.size) return [];
}
/* 2. 評分 + 原文驗證(消除 bigram 的假命中) */
return [...cand].map(i => {
const d = idx.docs[i];
let score = 0;
const inTitle = d.t.toLowerCase().includes(qq);
const inBody = d.x.toLowerCase().includes(qq);
if (inTitle) score += 100; // 標題完全含查詢字串
if (inBody) score += 30; // 內文完全含查詢字串
if (!inTitle && !inBody) score += 5; // 只有 bigram 命中(可能是分散字)
score += Math.min(20, (d.x.toLowerCase().split(qq).length - 1) * 3); // 出現次數
return { d, score, snippet: this.snippet(d.x, qq) };
}).sort((a, b) => b.score - a.score).slice(0, 30);
},
第 2 步是 bigram 準確度的解答。 倒排索引負責快速縮小候選(幾百課 → 幾課),然後用 includes(qq) 做原文精確驗證來評分。分散字的假命中不會被丟掉(有時候那正是使用者想要的模糊結果),但會排到最後面。
這是搜尋系統的通用架構:便宜的召回 + 精確的排序。自己寫也一樣適用。
摘要(snippet)與高亮:
snippet(text, q, span = 60) {
const i = text.toLowerCase().indexOf(q);
if (i < 0) return text.slice(0, span * 2) + "…";
const s = Math.max(0, i - span / 2);
return (s > 0 ? "…" : "") + text.slice(s, s + span * 2) + "…";
},
/* 高亮必須跳脫,因為 snippet 來自課文、q 來自使用者輸入 */
mark(text, q) {
const esc = s => s.replace(/[&<>"]/g, c => ({ "&":"&","<":"<",">":">",'"':""" }[c]));
const i = text.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return esc(text);
return esc(text.slice(0, i)) + "<mark>" + esc(text.slice(i, i + q.length)) + "</mark>"
+ esc(text.slice(i + q.length));
},
mark() 的跳脫是必要的,不是潔癖。 使用者輸入 <img src=x onerror=alert(1)> 當查詢字串,如果直接組進 innerHTML,就是一個現成的 XSS。這是本專案第二次遇到同一類問題(Day 9 是 localStorage),模式一樣:任何非我撰寫的字串進 innerHTML 前都要跳脫。
/ 聚焦、鍵盤選取 init() {
const input = document.getElementById("search-input");
const panel = document.getElementById("search-results");
if (!input || !panel) return;
let items = [], sel = -1, timer = null;
const run = () => {
items = this.query(input.value);
sel = -1;
panel.innerHTML = items.length ? items.map((r, i) => `
<a class="sr-item" href="chapter.html?ch=${r.d.ch}&l=${r.d.k.split("-")[1]}" data-i="${i}">
<div class="sr-title">${this.mark(r.d.t, input.value)}</div>
<div class="sr-path">${r.d.cn} · ${r.d.chn}</div>
<div class="sr-snip">${this.mark(r.snippet, input.value)}</div>
</a>`).join("")
: `<div class="sr-empty">找不到「${this.mark(input.value, "")}」相關的內容</div>`;
panel.classList.toggle("open", true);
};
/* debounce:中文輸入法組字期間會連續觸發 input */
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(run, 120); });
input.addEventListener("keydown", e => {
const els = [...panel.querySelectorAll(".sr-item")];
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
sel = Math.max(0, Math.min(els.length - 1, sel + (e.key === "ArrowDown" ? 1 : -1)));
els.forEach((el, i) => el.classList.toggle("cur", i === sel));
els[sel]?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && sel >= 0) { els[sel].click(); }
else if (e.key === "Escape") { panel.classList.remove("open"); input.blur(); }
});
/* 全站快捷鍵:/ 聚焦搜尋(在輸入框內不觸發) */
document.addEventListener("keydown", e => {
if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) {
e.preventDefault(); input.focus();
}
});
},
};
debounce 120ms 對中文特別重要:注音/拼音輸入法在組字過程中會不斷觸發 input 事件(「ㄒㄧㄣ」→「新」→「信」…),每次都跑一遍搜尋會明顯卡頓。
Escape 關閉、/ 聚焦、方向鍵選取——這些是搜尋框的基本禮儀,成本很低但少了會很明顯。
900 KB 的檔案不能無條件塞進每一頁。三個選項:
| 策略 | 說明 |
|---|---|
| 每頁都載入 | 簡單,但首頁多 900 KB,不可接受 |
獨立搜尋頁 search.html |
只有那頁載入,但要跳頁 |
點搜尋框才動態插入 script |
✅ 首屏零成本 |
loadIndex() {
if (this._loading) return this._loading;
this._loading = new Promise((res, rej) => {
if (window.SEARCH_INDEX) return res();
const s = document.createElement("script");
s.src = "js/data/search-index.js";
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
return this._loading;
}
動態插入 script 不受 fetch 的 CORS 限制,file:// 下同樣可用。這是純靜態網站做「按需載入」的通用手法(Day 19 會再用一次)。
Promise 快取(this._loading)避免使用者快速點兩次就載入兩份。
索引檔的體積被公式炸大。 第一版沒有濾掉 LaTeX,索引 1.8 MB。裡面有大量 alpha、beta、frac、sqrt、sigma 這種 token,各自對應上百課——既沒用又佔空間。濾掉公式與程式碼區塊後直接砍半。索引裡不該有沒人會搜的東西。
new Set(tokenize(text)) 的去重不能省。 一課裡「統計」出現 30 次,倒排表就會有 30 個重複的文件 id。第一版忘記去重,索引大了三成,而且交集運算變慢。
CSP 又來了。 動態插入 script src="js/data/search-index.js" 是同源,script-src 'self' 允許。但如果你把索引改成 eval 或 new Function 解析(有人會這樣做來省檔案大小),會被 CSP 直接擋死。這是 CSP 的正確行為,不要為了省幾 KB 去放寬它。
node scripts/build-search-index.js
# docs=256 tokens=48213 size=912KB
node scripts/verify.js # 確認沒動壞結構
python3 -m http.server 8901
功能檢查:
length < 2 的守衛)。/ 聚焦、方向鍵選取、Enter 進入、Escape 關閉。<img src=x onerror=alert(1)> → 不能跳 alert,畫面顯示跳脫後的純文字。search-index.js 請求。順手加進 smoke test:
d.get(BASE + "/index.html")
d.find_element(By.ID, "search-input").send_keys("信賴區間")
time.sleep(0.5)
if not d.find_elements(By.CSS_SELECTOR, ".sr-item"):
fails.append("search: 無結果")
今天的重點:
window.X = {...} 的 JS 檔以支援 file://。script 做按需載入,首屏零成本。innerHTML 前一律跳脫。明天做一個很「統計專用」但技術上很有趣的功能:統計數值表。傳統做法是查附表,我要直接算——自己實作 erf、不完全 gamma 與不完全 beta 函數,做出 z/t/χ²/F 的雙向查詢。