Files
store_ai_extension/src/background/service/autoScanScheduler.ts
2026-05-13 16:59:26 +08:00

232 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}