#!/usr/bin/env node /** * 從 knowledge.json 產生 skill-drills.json 題庫覆寫 * - 解析 EP 實例的標的、日期視窗 * - 同群心法作為第 1 關干擾項 */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.join(__dirname, "..", "data"); const KNOWLEDGE_PATH = path.join(DATA_DIR, "knowledge.json"); const OUT_PATH = path.join(DATA_DIR, "skill-drills.json"); const TICKER_IN_TEXT = /\b(NVDA|AMD|AAPL|MSFT|GOOGL|GOOGL|META|AMZN|TSLA|TSM|AVGO|NOC|BA|LMT|RTX|MU|SMCI|ORCL|CRM|COST|WMT|QQQ|SPY|SMH|IWM|LYV|DKNG|2330\.TW)\b/gi; const NAME_TO_SYMBOL = [ [/輝達|NVIDIA/i, "NVDA"], [/台積電|TSMC/i, "TSM"], [/蘋果|Apple/i, "AAPL"], [/微軟|Microsoft/i, "MSFT"], [/谷歌|Google|Alphabet/i, "GOOGL"], [/亞馬遜|Amazon/i, "AMZN"], [/特斯拉|Tesla/i, "TSLA"], [/博通|Broadcom/i, "AVGO"], [/洛克希德|Raytheon/i, "LMT"], [/萊茵金屬/i, "RTX"], [/台股|加權|大盤/i, "SPY"], [/標普|S&P|美股大盤/i, "SPY"], [/那斯達|Nasdaq|科技板塊/i, "QQQ"], ]; const GROUP_DEFAULT_SYMBOL = { 總經與市場水位: "SPY", 進攻與擴張時機: "QQQ", 護城河與商業模式: "NVDA", 財報估值與管理層: "AAPL", 交易紀律與資金管理: "SPY", 地緣政治與政策博弈: "LMT", 生活觀察與消費信號: "COST", 市場心理與反指標: "QQQ", }; function hashSeed(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; return Math.abs(h); } function pick(arr, seed, n) { const copy = [...arr]; const out = []; let s = hashSeed(seed); while (copy.length && out.length < n) { s = (s * 1103515245 + 12345) | 0; const idx = Math.abs(s) % copy.length; out.push(copy.splice(idx, 1)[0]); } return out; } function parseInstances(body) { const instances = []; for (const raw of String(body || "").split("\n")) { const line = raw.trim(); if (!line.startsWith("- ") && !line.startsWith("* ")) continue; const content = line.slice(2).trim(); if (/^(EP\d|EP\.|會員影片|member_)/i.test(content) || /^EP/.test(content.split(/[::]/)[0] || "")) { const parts = content.split(/[::]/); instances.push({ ep: parts[0]?.trim() || "", text: parts.slice(1).join(":").trim() || content, }); } } return instances; } function resolveSymbol(text, groupName) { const tickers = [...text.matchAll(TICKER_IN_TEXT)].map((m) => m[1].toUpperCase()); if (tickers[0]) return tickers[0]; for (const [re, sym] of NAME_TO_SYMBOL) { if (re.test(text)) return sym; } return GROUP_DEFAULT_SYMBOL[groupName] || "SPY"; } function parseScenarioWindow(text, ep) { const zh = text.match(/(20\d{2})\s*年\s*(\d{1,2})\s*月/); if (zh) return `${zh[1]}-${String(zh[2]).padStart(2, "0")}`; const slash = text.match(/(20\d{2})\s*[\/年]\s*(\d{1,2})\s*[\/月]\s*(\d{1,2})/); if (slash) return `${slash[1]}-${String(slash[2]).padStart(2, "0")}`; const en = text.match(/(20\d{2})-(\d{2})(?:-(\d{2}))?/); if (en) return `${en[1]}-${en[2]}`; const epNum = ep?.match(/EP\.?\s*(\d+)/i); if (epNum) return `EP${epNum[1]}`; return null; } function chartRangeForWindow(window) { if (!window || window.startsWith("EP")) return "1y"; const y = Number(window.slice(0, 4)); const now = new Date().getFullYear(); if (y >= now - 1) return "1y"; if (y >= now - 3) return "3y"; return "5y"; } function buildGroupMap(principleMapBody, principles) { const byId = new Map(principles.map((p) => [p.id, p])); const groupOf = new Map(); const groups = new Map(); if (!principleMapBody) return { groupOf, groups }; let curName = "全部心法"; for (const line of principleMapBody.split("\n")) { const hm = line.match(/^## \d+\.\s*(.+?)(/); if (hm) { curName = hm[1].trim(); if (!groups.has(curName)) groups.set(curName, []); continue; } const links = [...line.matchAll(/\[\[Emmy 投資心法#([^\]]+)\]\]/g)]; for (const m of links) { const p = byId.get(m[1]); if (!p) continue; groupOf.set(p.id, curName); groups.get(curName)?.push(p.id); } } return { groupOf, groups }; } function cleanTitle(title) { return String(title || "") .replace(/^原則[^::]+[::]\s*/, "") .trim(); } function main() { if (!fs.existsSync(KNOWLEDGE_PATH)) { console.error("找不到 knowledge.json,請先執行 npm run build:knowledge"); process.exit(1); } const knowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_PATH, "utf8")); const principles = knowledge.principles || []; const { groupOf, groups } = buildGroupMap(knowledge.principleMap?.body, principles); const entries = {}; for (const p of principles) { const instances = parseInstances(p.body); const groupName = groupOf.get(p.id) || "全部心法"; const idx = instances.length ? hashSeed(p.id) % instances.length : 0; const inst = instances[idx] || instances[0]; const blob = inst ? `${inst.ep} ${inst.text} ${p.title}` : p.title; const scenarioSymbol = resolveSymbol(blob, groupName); const scenarioWindow = inst ? parseScenarioWindow(`${inst.text} ${inst.ep}`, inst.ep) : null; const peers = (groups.get(groupName) || []).filter((id) => id !== p.id); const distractorPrinciples = pick(peers, `${p.id}:dist`, Math.min(3, peers.length)); const entry = { groupName, instanceIndex: idx, scenarioSymbol, chartRange: chartRangeForWindow(scenarioWindow), }; if (scenarioWindow) entry.scenarioWindow = scenarioWindow; if (inst?.ep) entry.epLabel = inst.ep; if (distractorPrinciples.length) entry.distractorPrinciples = distractorPrinciples; // 傳說/史詩卡加強提示 if (p.num != null && p.num <= 10) { entry.chartHint = "十大基石心法:結合總經水位與長期趨勢判斷"; } entries[p.id] = entry; } const payload = { version: 1, generatedAt: new Date().toISOString(), principleCount: principles.length, entries, }; fs.writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2), "utf8"); console.log(`Wrote ${OUT_PATH} (${principles.length} entries)`); } main();