145 lines
4.8 KiB
JavaScript
145 lines
4.8 KiB
JavaScript
// 日曆資料庫快取:以「日」為單位,預設 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);
|
|
}
|