172 lines
8.0 KiB
JavaScript
172 lines
8.0 KiB
JavaScript
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// 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`);
|