import {platformConfigs} from "@/config/platforms"; import {getAuthState} from "@/shared/store"; import {getCrawlTaskState} from "@/background/task/taskState"; import {startCrawl} from "@/background/task/crawlTask"; import {extensionHeartbeatApi, getScanQuotaApi} from "@/api/me"; const ALARM_MORNING = "DIANSHAN_AUTO_SCAN_MORNING"; const ALARM_EVENING = "DIANSHAN_AUTO_SCAN_EVENING"; let schedulerInited = false; /** * 初始化定时爬取调度器(只需要调用一次) * * 中文备注: * - MV3 service worker 可能被回收,模块会被重新执行;用 schedulerInited 防止重复注册监听 */ export function initAutoScanScheduler() { if (schedulerInited) return; schedulerInited = true; chrome.alarms.onAlarm.addListener((alarm) => { void handleAlarm(alarm.name); }); // 中文备注:启动时也做一次重建,避免用户只开扩展不打开浏览器启动事件时遗漏 void rescheduleAutoScanAlarms("init"); } /** * 重建定时爬取 alarms * * 中文备注: * - 需求:需要“调用接口拿到配置好的定时时间”,这里会先调用 `/api/me/scan-quota` 做一次拉取(并允许后端在该接口里下发定时字段) * - 如果接口没有下发定时字段,则回退到配对时存下来的 `authState.morningBriefHour/eveningRecapHour/timezone` */ export async function rescheduleAutoScanAlarms(reason: string) { const auth = getAuthState(); if (!auth?.token) { // 中文备注:未配对/未登录时,直接清掉所有自动爬取 alarm await chrome.alarms.clear(ALARM_MORNING); await chrome.alarms.clear(ALARM_EVENING); return; } // 中文备注:按需求“调用接口拿配置”,同时也能借机触发 ext_outdated/unauthorized 等错误暴露 let quota: any = null; try { quota = await getScanQuotaApi(); } catch { quota = null; } // 中文备注:配置字段优先级:接口下发 > authState 里缓存 const timezone = quota?.timezone || auth?.timezone || "Asia/Shanghai"; const morningHour = normaliseHour(quota?.morningBriefHour ?? quota?.morning_brief_hour ?? auth?.morningBriefHour); const eveningHour = normaliseHour(quota?.eveningRecapHour ?? quota?.evening_recap_hour ?? auth?.eveningRecapHour); // 中文备注:分别创建两个一次性 alarm;触发后会在 handleAlarm 里再次续约下一次 if (morningHour === null) { await chrome.alarms.clear(ALARM_MORNING); } else { const when = getNextRunAtMs(timezone, morningHour); chrome.alarms.create(ALARM_MORNING, {when}); } if (eveningHour === null) { await chrome.alarms.clear(ALARM_EVENING); } else { const when = getNextRunAtMs(timezone, eveningHour); chrome.alarms.create(ALARM_EVENING, {when}); } // 中文备注:心跳可选,用于后端观测扩展侧是否成功配置定时 void extensionHeartbeatApi({ skip_reason: null, ext_version: chrome.runtime.getManifest().version, tick_at: new Date().toISOString(), reason, }).catch(() => undefined); } async function handleAlarm(name: string) { if (name !== ALARM_MORNING && name !== ALARM_EVENING) return; const auth = getAuthState(); if (!auth?.token) { // 中文备注:没有 token 直接忽略,并清理 alarm await chrome.alarms.clear(name); return; } // 中文备注:如果当前已有任务(running/paused),本次自动扫描跳过,避免并发打开多个窗口 const state = await getCrawlTaskState(); if (state && ["running", "paused"].includes(state.status)) { void extensionHeartbeatApi({ skip_reason: "task_in_progress", ext_version: chrome.runtime.getManifest().version, tick_at: new Date().toISOString(), }).catch(() => undefined); await rescheduleAutoScanAlarms("skip_task_in_progress"); return; } // 中文备注:scheduled 扫描默认使用第一个平台(当前仅 Shopee) const platformId = platformConfigs[0]?.id; if (!platformId) { void extensionHeartbeatApi({ skip_reason: "no_platform_config", ext_version: chrome.runtime.getManifest().version, tick_at: new Date().toISOString(), }).catch(() => undefined); await rescheduleAutoScanAlarms("no_platform_config"); return; } // 中文备注:触发 scheduled 扫描 const started: any = await startCrawl(platformId, "scheduled"); if (started && typeof started === "object" && "error" in started && !("id" in started)) { void extensionHeartbeatApi({ skip_reason: String((started as any).error || "start_failed"), ext_version: chrome.runtime.getManifest().version, tick_at: new Date().toISOString(), }).catch(() => undefined); await rescheduleAutoScanAlarms("start_failed"); return; } // 中文备注:触发后立刻续约下一次(一次性 alarm) await rescheduleAutoScanAlarms("after_alarm_fired"); } function normaliseHour(v: any): number | null { if (v === null || v === undefined || v === "") return null; const n = Number(v); if (!Number.isFinite(n)) return null; if (n < 0 || n > 23) return null; return Math.floor(n); } /** * 计算“品牌时区”下,下一个整点触发时间(返回 UTC ms) * * 中文备注: * - 这里不用第三方库,靠 Intl + 时区偏移换算 * - 采用“一次性 alarm + 触发后续约”的方式,能更好适配 DST/时区变化 */ function getNextRunAtMs(timezone: string, targetHour: number) { const now = new Date(); const nowParts = getZonedParts(now, timezone); const nowTotalSec = nowParts.hour * 3600 + nowParts.minute * 60 + nowParts.second; const targetTotalSec = targetHour * 3600; // 中文备注:如果当前时区时间已经过了目标小时,则取下一天 const addDays = nowTotalSec >= targetTotalSec ? 1 : 0; const next = addDays === 0 ? {year: nowParts.year, month: nowParts.month, day: nowParts.day} : addDaysToYmd(nowParts.year, nowParts.month, nowParts.day, addDays); return zonedTimeToUtcMs(timezone, next.year, next.month, next.day, targetHour, 0, 0); } function getZonedParts(date: Date, timeZone: string) { const dtf = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); const parts = dtf.formatToParts(date); const out: any = {}; for (const p of parts) { if (p.type === "year" || p.type === "month" || p.type === "day" || p.type === "hour" || p.type === "minute" || p.type === "second") { out[p.type] = Number(p.value); } } return { year: out.year, month: out.month, day: out.day, hour: out.hour, minute: out.minute, second: out.second, }; } function addDaysToYmd(year: number, month: number, day: number, addDays: number) { const d = new Date(Date.UTC(year, month - 1, day + addDays, 0, 0, 0)); return {year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate()}; } function zonedTimeToUtcMs(timeZone: string, year: number, month: number, day: number, hour: number, minute: number, second: number) { // 中文备注:先用 UTC 作为“猜测”,再根据该时刻时区偏移修正到正确 UTC const guessUtcMs = Date.UTC(year, month - 1, day, hour, minute, second); let offsetMin = getTimeZoneOffsetMinutes(timeZone, new Date(guessUtcMs)); let adjusted = guessUtcMs - offsetMin * 60 * 1000; // 中文备注:DST 场景下,第一次修正后偏移可能变化,再算一次确保收敛 offsetMin = getTimeZoneOffsetMinutes(timeZone, new Date(adjusted)); adjusted = guessUtcMs - offsetMin * 60 * 1000; return adjusted; } function getTimeZoneOffsetMinutes(timeZone: string, date: Date) { const dtf = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "shortOffset", hour: "2-digit", minute: "2-digit", hour12: false, }); const parts = dtf.formatToParts(date); const tzName = parts.find((p) => p.type === "timeZoneName")?.value || "GMT+0"; // 兼容:GMT+8 / GMT+08:00 / GMT-5 const m = tzName.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/); if (!m) return 0; const sign = m[1] === "-" ? -1 : 1; const hh = Number(m[2] || 0); const mm = Number(m[3] || 0); return sign * (hh * 60 + mm); }