finance-dashboard/lib/calendar-i18n.js

181 lines
6.4 KiB
JavaScript
Raw Permalink Normal View History

2026-06-03 16:42:07 +00:00
// 日曆事件繁體中文化:規則對照優先,剩餘英文再用 MyMemory 免費 API結果快取
import { getCachedJSON, putCachedJSON } from './db.js';
const SOURCE_ZH = {
'Federal Reserve': '美國聯準會',
BLS: '美國勞動統計局',
BEA: '美國經濟分析局',
'Nasdaq earnings': 'Nasdaq 財報',
'Market calendar': '市場日曆',
'FRED releases': 'FRED 發布日程',
'Global markets': '全球市場',
ECB: '歐洲央行',
BOJ: '日本央行',
BOE: '英格蘭銀行',
};
const TITLE_RULES = [
[/Consumer Price Index for All Urban Consumers/i, 'CPI 消費者物價(通膨)'],
[/Consumer Price Index|CPI/i, 'CPI 通膨'],
[/Employment Situation|Nonfarm Payrolls|Nonfarm/i, '非農就業 / 失業率'],
[/Producer Price Index|PPI/i, 'PPI 生產者物價'],
[/Job Openings and Labor Turnover|Job Openings|JOLTS/i, 'JOLTS 職缺'],
[/Import and Export Price/i, '進出口物價'],
[/Real Earnings/i, '實質薪資'],
[/Gross Domestic Product|GDP/i, 'GDP 國內生產毛額'],
[/Personal Income and Outlays|Personal Income|Personal Outlays|PCE/i, 'PCE / 個人收入支出'],
[/International Trade in Goods and Services/i, '國際貿易'],
[/Corporate Profits/i, '企業利潤'],
[/FOMC Meeting Minutes|FOMC minutes/i, 'FOMC 會議紀要'],
[/FOMC.*SEP|SEP.*FOMC|dot plot/i, 'FOMC 利率決議 + 點陣圖'],
[/FOMC|Federal Open Market Committee/i, 'FOMC 利率決議'],
[/Retail Sales/i, '零售銷售'],
[/Industrial Production/i, '工業生產'],
[/Housing Starts|Building Permits/i, '住宅相關數據'],
[/Jackson Hole/i, 'Jackson Hole 全球央行年會'],
[/ECB 利率決議/i, 'ECB 利率決議'],
[/日本央行 利率決議/i, '日本央行 利率決議'],
[/英央行 MPC/i, '英央行 MPC 利率決議'],
[/初領失業|Jobless Claims/i, '初領失業救濟金'],
[/ADP/i, 'ADP 私部門就業'],
[/密西根|Surveys of Consumers/i, '密西根消費者信心'],
[/成屋銷售|Existing Home/i, '成屋銷售'],
[/新屋開工|New Residential Construction/i, '新屋開工 / 營建許可'],
[/耐久財|Manufacturer's Shipments/i, '耐久財 / 工廠接單出貨'],
[/工業生產|Industrial Production/i, '工業生產 / 產能利用率'],
[/零售銷售|Retail and Food Services/i, '零售銷售'],
[/月選擇權結算/i, '月選擇權結算'],
[/四巫日/i, '四巫日(衍生品結算)'],
[/美股休市/i, '美股休市'],
[/Employment Cost Index|ECI/i, '就業成本指數 ECI'],
[/Productivity and Costs/i, '生產力 / 單位成本'],
];
const PHRASE_RULES = [
[/Summary of Economic Projections/i, '經濟預測摘要'],
[/dot plot/i, '點陣圖'],
[/policy statement and interest rate decision/i, '政策聲明與利率決議'],
[/News Release/i, '新聞發布'],
[/U\.S\./gi, '美國'],
[/EPS forecast/i, 'EPS 預估'],
[/fiscal quarter ending/i, '財季截止'],
[/FOMC minutes/i, 'FOMC 會議紀要'],
[/Federal Reserve/i, '美國聯準會'],
[/Interest Rate Decision/i, '利率決議'],
[/All Urban Consumers/i, '所有城市消費者'],
[/seasonally adjusted/i, '季調'],
];
function hasLatin(text) {
return /[A-Za-z]{3,}/.test(String(text || ''));
}
function applyRules(text, rules) {
let out = String(text || '').trim();
if (!out) return '';
for (const [re, rep] of rules) out = out.replace(re, rep);
out = out
.replace(/\bET\b/g, '美東')
.replace(/\bEST\b/g, '美東')
.replace(/\bAM\b/g, '上午')
.replace(/\bPM\b/g, '下午')
.replace(/\s+/g, ' ')
.trim();
return out;
}
function localizeTitle(title) {
const t = String(title || '');
for (const [re, rep] of TITLE_RULES) {
if (re.test(t)) return rep;
}
return applyRules(t.replace(/^U\.S\.\s*/i, '').replace(/\s+News Release.*$/i, ''), TITLE_RULES);
}
function localizeNote(note) {
return applyRules(note, PHRASE_RULES);
}
function localizeTime(time) {
if (!time) return '';
return String(time)
.replace(/\bET\b/g, '美東')
.replace(/\bAM\b/g, '上午')
.replace(/\bPM\b/g, '下午')
.trim();
}
function localizeEarningsNote(note, symbol, name) {
const parts = [];
if (name && name !== symbol) parts.push(name);
const eps = String(note || '').match(/EPS forecast\s+([\d.\-]+)/i);
if (eps) parts.push(`EPS 預估 ${eps[1]}`);
const fq = String(note || '').match(/·\s*([\d/]+)\s*$/);
if (fq) parts.push(`財季 ${fq[1]}`);
return parts.join(' · ') || `${symbol} 財報`;
}
async function translateWithMyMemory(text) {
const src = String(text || '').trim().slice(0, 450);
if (!src || !hasLatin(src)) return src;
const cacheKey = `tr:en-zh-TW:${src}`;
const cached = getCachedJSON(cacheKey, 30 * 86400000);
if (cached) return cached;
try {
const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(src)}&langpair=en|zh-TW`;
const res = await fetch(url, { headers: { 'User-Agent': 'finance-dashboard/1.0' } });
if (!res.ok) return src;
const data = await res.json();
let out = data?.responseData?.translatedText || src;
if (out === src || /QUERY LENGTH LIMIT/i.test(out)) return src;
out = out.replace(/"/g, '"').replace(/'/g, "'").trim();
putCachedJSON(cacheKey, out);
return out;
} catch {
return src;
}
}
async function ensureZh(text, apiBudget) {
let out = applyRules(text, PHRASE_RULES);
if (!hasLatin(out)) return out;
if (apiBudget.remaining <= 0) return out;
apiBudget.remaining--;
return translateWithMyMemory(out);
}
export async function localizeCalendarPayload(payload) {
const apiBudget = { remaining: 24 };
const events = [];
for (const ev of payload.events || []) {
let title = ev.title || '';
let note = ev.note || '';
if (ev.category === 'earnings') {
title = title.includes('財報') ? title : `${ev.symbol || ''} 財報`.trim();
note = localizeEarningsNote(note, ev.symbol, (note.split('·')[0] || '').trim());
} else {
title = localizeTitle(title);
note = localizeNote(note);
if (hasLatin(title)) title = await ensureZh(title, apiBudget);
if (hasLatin(note)) note = await ensureZh(note, apiBudget);
}
events.push({
...ev,
title,
note,
time: localizeTime(ev.time),
source: SOURCE_ZH[ev.source] || ev.source,
});
}
return {
...payload,
events,
sources: (payload.sources || []).map(s => ({
...s,
name: SOURCE_ZH[s.name] || s.name,
ok: s.ok,
error: s.error,
})),
};
}