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(); |