// 日曆資料庫快取:以「日」為單位,預設 24 小時內不重抓;重啟 server 仍保留 import { buildCalendar } from './calendar.js'; import { localizeCalendarPayload } from './calendar-i18n.js'; import { getCachedEntry, putCachedJSON } from './db.js'; export const CALENDAR_DAY_MS = (Number(process.env.CALENDAR_TTL_HOURS) || 24) * 3600 * 1000; export function calendarCacheDay(d = new Date()) { return d.toISOString().slice(0, 10); } function baseKey(day) { return `calendar:base:v5:${day}`; } function earnKey(day, symbols) { return `calendar:earn:v5:${day}:${[...symbols].sort().join(',') || '_'}`; } function watchlistKey() { return 'calendar:watchlist:v1'; } export function getCalendarWatchlist() { const row = getCachedEntry(watchlistKey()); const val = row?.value; return Array.isArray(val) ? val : []; } export function saveCalendarWatchlist(symbols) { const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30); putCachedJSON(watchlistKey(), clean); return clean; } function isFresh(entry, day) { if (!entry?.value) return false; if (entry.value.cacheDay && entry.value.cacheDay !== day) return false; return Date.now() - entry.updatedAt < CALENDAR_DAY_MS; } function mergeEvents(baseEvents, earnEvents) { const seen = new Set(); return [...(baseEvents || []), ...(earnEvents || [])] .filter(ev => { const key = `${ev.date}|${ev.time}|${ev.category}|${ev.symbol || ''}|${ev.title}`; if (seen.has(key)) return false; seen.add(key); return true; }) .sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time)); } function filterRange(events, start, end) { return (events || []).filter(ev => ev.date >= start && ev.date <= end); } function wrapResponse(payload, entry, { cached, stale, fetchError } = {}) { return { ...payload, cached: !!cached, stale: !!stale, fetchError: fetchError || null, cachedAt: entry?.updatedAt ? new Date(entry.updatedAt).toISOString() : payload.updatedAt, cacheDay: payload.cacheDay, }; } async function buildAndStore({ start, end, symbols, key, day }) { const payload = await buildCalendar({ start, end, symbols }); const localized = await localizeCalendarPayload(payload); localized.cacheDay = day; localized.cachedAt = new Date().toISOString(); putCachedJSON(key, localized); return { value: localized, updatedAt: Date.now() }; } export async function getCalendarPayload({ start, end, symbols, forceFresh = false }) { const day = calendarCacheDay(); const sym = [...symbols].sort(); let baseEntry = getCachedEntry(baseKey(day)); let earnEntry = sym.length ? getCachedEntry(earnKey(day, sym)) : null; const needBase = forceFresh || !isFresh(baseEntry, day); const needEarn = sym.length > 0 && (forceFresh || !isFresh(earnEntry, day)); try { if (needBase) { baseEntry = await buildAndStore({ start, end, symbols: [], key: baseKey(day), day }); } if (needEarn) { earnEntry = await buildAndStore({ start, end, symbols: sym, key: earnKey(day, sym), day }); } const baseEvents = (baseEntry.value.events || []).filter(e => e.category !== 'earnings'); const fullEarn = earnEntry?.value?.events || []; const earnEvents = sym.length ? fullEarn.filter(e => e.category === 'earnings') : []; const events = filterRange(mergeEvents(baseEvents, earnEvents), start, end); const sources = baseEntry.value.sources || []; const payload = { updatedAt: new Date().toISOString(), cacheDay: day, start, end, symbols: sym, events, sources, }; return wrapResponse(payload, baseEntry, { cached: !needBase && !needEarn }); } catch (err) { const staleBase = baseEntry?.value; const staleEarn = earnEntry?.value; if (staleBase) { const baseEvents = (staleBase.events || []).filter(e => e.category !== 'earnings'); const earnEvents = sym.length ? (staleEarn?.events || []).filter(e => e.category === 'earnings') : []; const events = filterRange(mergeEvents(baseEvents, earnEvents), start, end); return wrapResponse({ updatedAt: staleBase.updatedAt, cacheDay: staleBase.cacheDay || day, start, end, symbols: sym, events, sources: staleBase.sources || [], }, baseEntry, { cached: true, stale: true, fetchError: String(err?.message || err) }); } throw err; } } export async function warmCalendarCache() { const today = calendarCacheDay(); const start = today; const end = addDaysISO(today, 60); if (isFresh(getCachedEntry(baseKey(today)), today)) return; await buildAndStore({ start, end, symbols: [], key: baseKey(today), day: today }); } function addDaysISO(iso, days) { const d = new Date(iso + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); }