This commit is contained in:
zhu
2026-05-13 16:59:26 +08:00
parent cb5a13d352
commit 2d1397c277
18 changed files with 1281 additions and 285 deletions

View File

@@ -0,0 +1,231 @@
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);
}