// ═══════════════════════════════════════════════════════════ // build-knowledge.mjs // 把 ../emmy/emmy 的 Obsidian 知識庫快照成兩個 JSON: // - data/knowledge.json : 課綱/心法/案例/分類 全文 + 名詞/公司/單集的輕量索引 + linkMap // - data/notes.json : 所有筆記全文(key = `${kind}:${id}`),給 /api/note 即時查單篇 // emmy/ 在 web/ 之外,因此用建置腳本快照,不在執行期讀檔。 // 用法:cd web && npm run build:knowledge // ═══════════════════════════════════════════════════════════ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const EMMY = path.resolve(__dirname, '..', '..', 'emmy', 'emmy'); const OUT_DIR = path.resolve(__dirname, '..', 'data'); if (!fs.existsSync(EMMY)) { console.error(`找不到知識庫資料夾:${EMMY}\n請確認 emmy/emmy 與 web/ 在同一層。`); process.exit(1); } // ── 小工具 ── function parseFrontmatter(raw) { if (!raw.startsWith('---')) return { fm: {}, body: raw }; const end = raw.indexOf('\n---', 3); if (end < 0) return { fm: {}, body: raw }; const fmText = raw.slice(3, end).trim(); const body = raw.slice(end + 4).replace(/^\s*\n/, ''); const fm = {}; for (const line of fmText.split('\n')) { const m = line.match(/^([\w\u4e00-\u9fff]+):\s*(.*)$/); if (!m) continue; let [, k, v] = m; v = v.trim(); if (v.startsWith('[') && v.endsWith(']')) v = v.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean); else v = v.replace(/^["']|["']$/g, ''); fm[k] = v; } return { fm, body }; } const firstHeading = (body) => { const m = body.match(/^#\s+(.+)$/m); return m ? m[1].trim() : null; }; function summarize(body) { for (let l of body.split('\n')) { l = l.trim(); if (!l || /^#/.test(l) || /^[-|]/.test(l) || /^type:/.test(l)) continue; if (l.startsWith('>')) l = l.replace(/^>\s?/, ''); l = l.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*`#]/g, '').trim(); if (l.length > 4) return l.slice(0, 90); } return ''; } function readDir(sub) { const dir = path.join(EMMY, sub); if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir) .filter(f => f.endsWith('.md') && !f.endsWith('.bak') && !f.startsWith('.') && !f.startsWith('_')) .map(f => { const full = path.join(dir, f); if (!fs.statSync(full).isFile()) return null; const raw = fs.readFileSync(full, 'utf8'); if (!raw.trim()) return null; const id = f.replace(/\.md$/, ''); const { fm, body } = parseFrontmatter(raw); return { id, title: firstHeading(body) || id, fm, body }; }) .filter(Boolean); } // ── 累積器 ── const linkMap = {}; const notes = {}; const setLink = (key, val, overwrite = true) => { if (key && (overwrite || !linkMap[key])) linkMap[key] = val; }; const addNote = (kind, n) => { notes[`${kind}:${n.id}`] = { kind, id: n.id, title: n.title, frontmatter: n.fm || {}, body: n.body }; }; // ── 1. 學習分類(含 總覽 / 心法地圖 / 練習題庫)── const SPECIAL = { '總覽': 'overview', '心法地圖': 'principleMap', '練習題庫': 'quiz' }; let overview = null, principleMap = null, quiz = null; const categories = []; for (const n of readDir('學習分類')) { const node = { id: n.id, title: n.title, body: n.body, summary: summarize(n.body), frontmatter: n.fm }; const special = SPECIAL[n.id]; if (special === 'overview') { overview = node; setLink('學習分類/總覽', { kind: 'overview', id: n.id, title: n.title }); } else if (special === 'principleMap') { principleMap = node; setLink('學習分類/心法地圖', { kind: 'principleMap', id: n.id, title: n.title }); } else if (special === 'quiz') { quiz = node; setLink('學習分類/練習題庫', { kind: 'quiz', id: n.id, title: n.title }); } else categories.push(node); const kind = special || 'category'; setLink(`學習分類/${n.id}`, { kind, id: n.id, title: n.title }); setLink(n.id, { kind, id: n.id, title: n.title }, false); addNote(kind, n); } // ── 2. 案例講解 ── const cases = []; for (const n of readDir('案例講解')) { cases.push({ id: n.id, title: n.title, body: n.body, summary: summarize(n.body), frontmatter: n.fm }); setLink(`案例講解/${n.id}`, { kind: 'case', id: n.id, title: n.title }); setLink(n.id, { kind: 'case', id: n.id, title: n.title }, false); addNote('case', n); } // ── 3. 投資心法(單檔切成多條原則)── const principles = []; { const file = path.join(EMMY, 'Emmy 投資心法.md'); if (fs.existsSync(file)) { const lines = fs.readFileSync(file, 'utf8').split('\n'); let cur = null; const push = () => { if (cur) { cur.body = cur._lines.join('\n').trim(); delete cur._lines; principles.push(cur); } }; for (const line of lines) { const m = line.match(/^##\s+(原則.+?)\s*$/); if (m) { push(); cur = { id: m[1].trim(), title: m[1].trim(), _lines: ['# ' + m[1].trim()] }; } else if (cur) cur._lines.push(line); } push(); principles.forEach((p, i) => { p.num = i + 1; setLink(`Emmy 投資心法#${p.id}`, { kind: 'principle', id: p.id, title: p.title }); setLink(p.id, { kind: 'principle', id: p.id, title: p.title }, false); addNote('principle', { id: p.id, title: p.title, body: p.body, fm: {} }); }); } } // ── 4. 名詞 / 公司 / 單集(輕量索引 + 全文存 notes)── const index = []; for (const n of readDir('名詞')) { const aliases = Array.isArray(n.fm.aliases) ? n.fm.aliases : []; index.push({ kind: 'term', id: n.id, title: n.title, aliases, sub: n.fm.category || '' }); setLink(`名詞/${n.id}`, { kind: 'term', id: n.id, title: n.title }); setLink(n.id, { kind: 'term', id: n.id, title: n.title }, false); aliases.forEach(a => setLink(a, { kind: 'term', id: n.id, title: n.title }, false)); addNote('term', n); } for (const n of readDir('公司')) { const ticker = Array.isArray(n.fm.ticker) ? n.fm.ticker.join(' / ') : (n.fm.ticker || ''); index.push({ kind: 'company', id: n.id, title: n.title, aliases: ticker ? [ticker] : [], sub: [n.fm.sector, ticker].filter(Boolean).join(' · ') }); setLink(`公司/${n.id}`, { kind: 'company', id: n.id, title: n.title }); setLink(n.id, { kind: 'company', id: n.id, title: n.title }, false); addNote('company', n); } for (const n of readDir('單集')) { index.push({ kind: 'episode', id: n.id, title: n.title, aliases: n.fm.episode ? [n.fm.episode] : [], sub: n.fm.date || '' }); setLink(`單集/${n.id}`, { kind: 'episode', id: n.id, title: n.title }); setLink(n.id, { kind: 'episode', id: n.id, title: n.title }, false); if (n.fm.episode) setLink(n.fm.episode, { kind: 'episode', id: n.id, title: n.title }, false); addNote('episode', n); } const counts = { terms: index.filter(x => x.kind === 'term').length, companies: index.filter(x => x.kind === 'company').length, episodes: index.filter(x => x.kind === 'episode').length, }; const knowledge = { generatedAt: new Date().toISOString(), overview, principleMap, quiz, categories, cases, principles, index, counts, linkMap, }; fs.mkdirSync(OUT_DIR, { recursive: true }); fs.writeFileSync(path.join(OUT_DIR, 'knowledge.json'), JSON.stringify(knowledge)); fs.writeFileSync(path.join(OUT_DIR, 'notes.json'), JSON.stringify(notes)); console.log('知識庫建置完成:'); console.log(` 學習分類 ${categories.length} 案例 ${cases.length} 心法 ${principles.length}`); console.log(` 名詞 ${counts.terms} 公司 ${counts.companies} 單集 ${counts.episodes}`); console.log(` linkMap ${Object.keys(linkMap).length} 個鍵 notes ${Object.keys(notes).length} 篇`); console.log(` 輸出 → ${path.relative(process.cwd(), path.join(OUT_DIR, 'knowledge.json'))}, notes.json`);