232 lines
8.6 KiB
TypeScript
232 lines
8.6 KiB
TypeScript
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);
|
||
}
|