194 lines
6.2 KiB
JavaScript
194 lines
6.2 KiB
JavaScript
|
|
#!/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();
|