finance-dashboard/scripts/build-knowledge.mjs

172 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════
// 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`);