#!/usr/bin/env node /** * Build search-index.json from chapter HTML files. * * docs/prd/chapters/{ch}/**.html → assets/search-index.json * * Tokenization rules (must match runtime in app.js): * - lowercase * - ASCII words/digits: [a-z0-9_]+ * - CJK bigrams: [一-鿿]+ → for each run, push every 2-char window * * Output: * { * docs: [ { id, title, href, crumbs:[…], text } ], * invIndex: { token: [ [docId, tf], … ] } * } */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const CHAPT_DIR = path.join(ROOT, 'chapters'); const OUT = path.join(ROOT, 'assets', 'search-index.json'); function walk(dir) { const out = []; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) out.push(...walk(p)); else if (e.isFile() && e.name.endsWith('.html')) out.push(p); } return out; } function stripTags(html) { return html .replace(//gi, ' ') .replace(//gi, ' ') .replace(//g, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim(); } function pick(html, re) { const m = re.exec(html); return m ? stripTags(m[1]) : null; } function extractTitle(html) { return pick(html, /]*>([\s\S]*?)<\/h1>/i) || pick(html, /]*>([\s\S]*?)<\/title>/i) || '(无标题)'; } function extractCrumbs(html) { const m = /
]*>([\s\S]*?)<\/div>/i.exec(html); if (!m) return []; return m[1].replace(/]*>›<\/span>/g, '|').split('|').map(s => stripTags(s)).filter(Boolean); } function tokenize(text) { const tokens = []; const lower = text.toLowerCase(); (lower.match(/[a-z0-9_]+/g) || []).forEach(w => tokens.push(w)); const cjk = lower.match(/[㐀-鿿]+/g) || []; cjk.forEach(run => { if (run.length === 1) tokens.push(run); else for (let i = 0; i < run.length - 1; i++) tokens.push(run.slice(i, i + 2)); }); return tokens; } function build() { const files = walk(CHAPT_DIR); const docs = []; const inv = {}; // token → Map for (const file of files) { const html = fs.readFileSync(file, 'utf-8'); const title = extractTitle(html); const crumbs = extractCrumbs(html); const text = stripTags(html); const href = path.relative(CHAPT_DIR, file); const id = docs.length; docs.push({ id, title, href, crumbs, text: text.slice(0, 2000) }); const tokens = tokenize(text); const tf = {}; for (const t of tokens) tf[t] = (tf[t] || 0) + 1; for (const [t, c] of Object.entries(tf)) { (inv[t] = inv[t] || []).push([id, c]); } } const out = { docs, invIndex: inv }; fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, JSON.stringify(out)); const kb = (fs.statSync(OUT).size / 1024).toFixed(1); console.log(`✅ search index: ${docs.length} docs, ${Object.keys(inv).length} tokens, ${kb} KB → ${path.relative(ROOT, OUT)}`); } build();