gacha-calendar-core 0.10.8 → 0.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/core.mjs +391 -176
  2. package/package.json +1 -1
package/core.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // 用途:缓存里记录"这份数据是哪版插件产出的"。更新插件后首次启动,据此**强制**刷新一次
5
5
  // (不看自动刷新开关)——因为有些改动(悬停格式、来源地址、样式、解析器)不刷新就看不到效果。
6
6
  // 写入时机见 engine-api.js 的 refresh();判定见 60-helpers.js 的 autoRefreshPlan()。
7
- const PLUGIN_VERSION = "0.10.8";
7
+ const PLUGIN_VERSION = "0.10.9";
8
8
  //#endregion
9
9
 
10
10
  //#region config
@@ -54,6 +54,18 @@
54
54
  // 默认活动源与卡池源相同时(ba-*/r1999 的公告同时含卡池与活动),仍作为独立来源存在,
55
55
  // 抓取时同 URL 单次请求复用,换用其他来源时独立抓取。
56
56
  // 明日方舟来源地址:默认=官方公告 CMS(web-news.hypergryph.com);PRTS 卡池一览为备选(mediawiki,浏览器直连)
57
+ // —— 源站时区(`tz`)——
58
+ // 每个条目声明其**源站墙上时钟所用时区**,由 15-env.js 的 sourceInstant() 换算成绝对时刻。
59
+ // 只影响"绝对时刻"(倒计时 / 是否在开),**显示文本仍是源站墙钟原文**(与游戏内公告一致)。
60
+ // 取值依据(2026-10-01 实测源站标注,详见交接文档 §59):
61
+ // · 国服系(原神/星铁/绝区零/鸣潮/方舟/终末地/蔚蓝国服/1999/异环)→ UTC+8
62
+ // 其中绝区零正文标 `UTC+8`×17、鸣潮 `UTC+8`×10、1999 `UTC+8`×1
63
+ // · 蔚蓝日服 → `Asia/Tokyo`(正文标 `JST`×3)
64
+ // · 蔚蓝国际服 → `UTC`(国际服标准重置点 = UTC 01:59,公告写"上午9點59分")
65
+ // · 未声明 → 沿用本机时区(与改造前一致)
66
+ const TZ_CN = "Asia/Shanghai"; // UTC+8:国服一览
67
+ const TZ_JP = "Asia/Tokyo"; // UTC+9:日服
68
+ const TZ_UTC = "UTC"; // UTC :国际服
57
69
  const AK_OFFICIAL_BULLETIN = "https://web-news.hypergryph.com/api/bulletin";
58
70
  const ARKNIGHTS_OFFICIAL_LIST_URL = AK_OFFICIAL_BULLETIN + "?lang=zh-cn&code=arknights&page=1&pageSize=30";
59
71
  const ARKNIGHTS_PRTS_URL = "https://prts.wiki/api.php?action=parse&page=%E5%8D%A1%E6%B1%A0%E4%B8%80%E8%A7%88&prop=text&format=json&formatversion=2";
@@ -82,6 +94,7 @@
82
94
  const SOURCES = [
83
95
  {
84
96
  id: "genshin",
97
+ tz: TZ_CN,
85
98
  // parserVersion:该条目「解析逻辑」的版本号 —— 源站改版/规则更新后 +1。
86
99
  // 用途:无服务端分发时定位「坏了的是哪个版本的用户、哪个源」(见交接文档 §13.4)。
87
100
  parserVersion: 1,
@@ -95,6 +108,7 @@
95
108
  },
96
109
  {
97
110
  id: "hsr",
111
+ tz: TZ_CN,
98
112
  parserVersion: 1,
99
113
  name: "崩坏:星穹铁道",
100
114
  icon: "https://storage.moegirl.org.cn/moegirl/commons/3/38/HonkaiStarRailIcon_StartingVer3.6_CHN.png!/fw/64",
@@ -106,6 +120,7 @@
106
120
  },
107
121
  {
108
122
  id: "zzz",
123
+ tz: TZ_CN,
109
124
  parserVersion: 2,
110
125
  name: "绝区零",
111
126
  icon: "https://storage.moegirl.org.cn/moegirl/commons/3/3e/ZZZ_miYoYo_logo.jpg!/fw/64",
@@ -132,6 +147,7 @@
132
147
  },
133
148
  {
134
149
  id: "wuwa",
150
+ tz: TZ_CN,
135
151
  parserVersion: 2,
136
152
  name: "鸣潮",
137
153
  icon: "https://storage.moegirl.org.cn/moegirl/commons/2/29/WutheringWavesIcon.png!/fw/64",
@@ -145,7 +161,7 @@
145
161
  { label: "Bwiki 角色轮换池", url: WUWA_BWIKI_URL, fetcher: "wuwa-bwiki" }
146
162
  ],
147
163
  // 活动源:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动,见 parseWuwaRecommendEvents),
148
- // 与卡池同址 → 一次请求复用两侧数据。
164
+ // 与卡池是**同一条 URL** → 一次请求复用两侧数据。
149
165
  // ⚠️ 2026-10-01 改:Bwiki 活动日历页**已停更**(最新一条结束于 2026/9/29,无 3.7 内容),
150
166
  // 所以把它从默认**降级为备选**(eventAltSources),仍可在设置里手动切回。
151
167
  eventUrl: WUWA_NOTICE_ENTRY,
@@ -156,6 +172,7 @@
156
172
  },
157
173
  {
158
174
  id: "arknights",
175
+ tz: TZ_CN,
159
176
  // 2:档位改按池名判定(中坚优先)+ 外显与悬停共用同一份排序列表(v0.9.25 修)
160
177
  parserVersion: 2,
161
178
  name: "明日方舟",
@@ -173,6 +190,7 @@
173
190
  },
174
191
  {
175
192
  id: "endfield",
193
+ tz: TZ_CN,
176
194
  parserVersion: 2,
177
195
  name: "明日方舟:终末地",
178
196
  icon: "https://storage.moegirl.org.cn/moegirl/commons/f/f1/ArknightsEndfieldAppIcon.png!/fw/64",
@@ -204,6 +222,7 @@
204
222
  },
205
223
  {
206
224
  id: "ba-cn",
225
+ tz: TZ_CN,
207
226
  parserVersion: 1,
208
227
  name: "蔚蓝档案·国服",
209
228
  icon: "https://webcnstatic.yostar.net/ba_cn_web/prod/web/favicon.png?x-oss-process=image/resize,w_64",
@@ -216,6 +235,7 @@
216
235
  },
217
236
  {
218
237
  id: "ba-global",
238
+ tz: TZ_UTC,
219
239
  parserVersion: 1,
220
240
  name: "蔚蓝档案·国际服",
221
241
  icon: "https://storage.moegirl.org.cn/moegirl/commons/2/25/AppIcon_Arona.png!/fw/64",
@@ -236,6 +256,7 @@
236
256
  },
237
257
  {
238
258
  id: "ba-jp",
259
+ tz: TZ_JP,
239
260
  parserVersion: 1,
240
261
  name: "蔚蓝档案·日服",
241
262
  icon: "https://play-lh.googleusercontent.com/H975s6W1-boCSogzpF5_rIyawbjiXfG842ncgjIRiVGzhXHFTCVut0DkBhlDR4CgN1nn98OOC1fWN-LE7kUHnQ=s64",
@@ -257,6 +278,7 @@
257
278
  },
258
279
  {
259
280
  id: "r1999",
281
+ tz: TZ_CN,
260
282
  parserVersion: 3,
261
283
  name: "重返未来:1999",
262
284
  icon: "https://play-lh.googleusercontent.com/LwcueZMBbLq6aELtqJVn61ToKkJUgxEO8O4KgK_5052hfYoDAglQJIzqSu8srUJeaOZwv36Qi5YKtsXZjo-JPg=s64",
@@ -270,7 +292,7 @@
270
292
  altSources: [
271
293
  { label: "\u5B98\u7F51\u516C\u544A+\u5C0F\u7C73", url: R1999_OFFICIAL_URL, fetcher: "r1999-official" }
272
294
  ],
273
- // 活动源与卡池源同址(同一篇「版本活动一览」同时含征集与活动),仍作为独立来源存在
295
+ // 活动源与卡池源是**同一条 URL**(同一篇「版本活动一览」同时含征集与活动),仍作为独立来源存在
274
296
  eventUrl: R1999_NOTICE_URL,
275
297
  eventSource: "\u5B98\u65B9\u516C\u544A",
276
298
  eventAltSources: [
@@ -279,6 +301,7 @@
279
301
  },
280
302
  {
281
303
  id: "nte",
304
+ tz: TZ_CN,
282
305
  parserVersion: 2,
283
306
  name: "异环",
284
307
  icon: "https://storage.moegirl.org.cn/moegirl/commons/8/8c/YH_APP.png!/fw/64",
@@ -822,6 +845,104 @@ export function createEngine(env) {
822
845
  return h;
823
846
  }
824
847
 
848
+ //#region 源站时区(显式建模)
849
+ //
850
+ // **为什么需要**:此前一律用 `new Date(y, mo-1, d, h, mi)` 把源站墙钟时间按**本机时区**
851
+ // 解释。对国内用户(UTC+8)恰好正确,但:
852
+ // · 海外用户用国服插件 → 绝对时刻偏移(倒计时、"是否在开" 判定会错)
853
+ // · 日服/国际服与国服混用 → 同一份数据在不同机器上得到不同时刻
854
+ // 现在每个来源显式声明 `tz`(源站墙上时钟所用时区),由这里换算成正确的绝对时刻。
855
+ //
856
+ // **注意**:只改"绝对时刻",**显示文本仍是源站墙钟原文** —— 玩家看游戏内公告
857
+ // 走的就是那串墙钟时间,改成用户本地时间反而对不上。
858
+ //
859
+ // 表示法与取值(均按 2026-10-01 实测的源站标注确定,见交接文档 §59):
860
+ // · IANA 名(`UTC` / `Asia/Shanghai` / `Asia/Tokyo` / `Europe/Berlin` …)—— 支持 DST
861
+ // · 或固定偏移分钟数(`480` = UTC+8 / `540` = UTC+9 / `0` = UTC)
862
+ // 未声明(`null` / `undefined`)→ **沿用本机时区**(与改造前完全一致,向后兼容)
863
+ const TZ_FMT_CACHE = new Map();
864
+ function tzFormatter(tzName) {
865
+ let f = TZ_FMT_CACHE.get(tzName);
866
+ if (f === void 0) {
867
+ try {
868
+ f = new Intl.DateTimeFormat("en-US", {
869
+ timeZone: tzName, hour12: false,
870
+ year: "numeric", month: "2-digit", day: "2-digit",
871
+ hour: "2-digit", minute: "2-digit", second: "2-digit"
872
+ });
873
+ } catch { f = null; } // 环境不支持该时区名(ICU 缺失)→ 退化为本机时区
874
+ TZ_FMT_CACHE.set(tzName, f);
875
+ }
876
+ return f;
877
+ }
878
+
879
+ // 某时刻在某时区的偏移(分钟,东为正)。IANA 名不可用时返回 null。
880
+ // 做法:先把 ts 当作 UTC 取各字段,再用目标时区渲染同一 ts,两套墙上时钟之差即偏移。
881
+ function tzOffsetMinutesAt(tzName, ts) {
882
+ const f = tzFormatter(tzName);
883
+ if (!f) return null;
884
+ const d = new Date(ts);
885
+ if (isNaN(d.getTime())) return null;
886
+ const parts = f.formatToParts(d);
887
+ const g = (k) => { const p = parts.find((x) => x.type === k); return p ? Number(p.value) : NaN; };
888
+ const h = g("hour") % 24; // hour12:false 在个别环境对午夜给 24 → 归零
889
+ const asUTC = Date.UTC(g("year"), g("month") - 1, g("day"), h, g("minute"), g("second"));
890
+ return (asUTC - (Math.floor(ts / 1000) * 1000)) / 60000;
891
+ }
892
+
893
+ // 源站时区表示 → 该时刻的偏移(分钟)。固定数字直接用;IANA 名按 ts 求(含 DST);无法判定 → null(本机时区)
894
+ function sourceOffsetMinutes(tz, ts) {
895
+ if (tz == null || tz === "") return null;
896
+ if (typeof tz === "number") return Number.isFinite(tz) ? tz : null;
897
+ if (typeof tz === "string") {
898
+ if (/^[+-]?\d+$/.test(tz.trim())) return Number(tz.trim());
899
+ return tzOffsetMinutesAt(tz.trim(), ts);
900
+ }
901
+ return null;
902
+ }
903
+
904
+ // 绝对毫秒 → 在**源站时区**下的墙上时钟字段(供"把 ts 渲染回源站墙钟"用)。
905
+ // tz 为 null(或时区名不可用)→ 退化为本机时区字段。
906
+ function sourceWallParts(ts, tz) {
907
+ const d = new Date(ts);
908
+ if (tz == null || tz === "") {
909
+ return { y: d.getFullYear(), mo: d.getMonth() + 1, d: d.getDate(), h: d.getHours(), mi: d.getMinutes() };
910
+ }
911
+ if (typeof tz === "string" && !/^[+-]?\d+$/.test(tz.trim())) {
912
+ const f = tzFormatter(tz.trim());
913
+ if (f) {
914
+ const parts = f.formatToParts(d);
915
+ const g = (k) => { const p = parts.find((x) => x.type === k); return p ? Number(p.value) : NaN; };
916
+ return { y: g("year"), mo: g("month"), d: g("day"), h: g("hour") % 24, mi: g("minute") };
917
+ }
918
+ return { y: d.getFullYear(), mo: d.getMonth() + 1, d: d.getDate(), h: d.getHours(), mi: d.getMinutes() };
919
+ }
920
+ // 固定偏移(数字或 "+8" 形式)→ 平移后再取 UTC 字段
921
+ const off = sourceOffsetMinutes(tz, ts);
922
+ if (off == null) {
923
+ return { y: d.getFullYear(), mo: d.getMonth() + 1, d: d.getDate(), h: d.getHours(), mi: d.getMinutes() };
924
+ }
925
+ const s = new Date(ts + off * 60000);
926
+ return { y: s.getUTCFullYear(), mo: s.getUTCMonth() + 1, d: s.getUTCDate(), h: s.getUTCHours(), mi: s.getUTCMinutes() };
927
+ }
928
+
929
+ // 源站墙钟(y-mo-d h:mi)→ 绝对毫秒。tz 为 null 时用本机时区(= 改造前行为)。
930
+ // IANA 名走两遍:先用"把墙钟当 UTC"估一个时刻求偏移,再用该偏移定出真实时刻;
931
+ // 偏移在真实时刻与估计时刻不同(正好跨 DST 边界)时再迭代一次 —— 与 Temporal 的
932
+ // "compatible" 消歧一致,且对游戏源站(多为固定 +8/+9/UTC)根本用不到第二遍。
933
+ function sourceInstant(y, mo, d, h, mi, tz) {
934
+ if (tz == null || tz === "") return new Date(y, mo - 1, d, h, mi).getTime();
935
+ const guess = Date.UTC(y, mo - 1, d, h, mi);
936
+ let off = sourceOffsetMinutes(tz, guess);
937
+ if (off == null) return new Date(y, mo - 1, d, h, mi).getTime(); // 时区名不可用 → 保持原行为
938
+ let ts = guess - off * 60000;
939
+ const off2 = sourceOffsetMinutes(tz, ts);
940
+ if (off2 != null && off2 !== off) ts = guess - off2 * 60000;
941
+ return ts;
942
+ }
943
+ //#endregion
944
+
945
+
825
946
  // 经代理抓取文本(需要绕过 CORS / Referer 反爬的源)。语义与原 host 代理调用完全一致:
826
947
  // referer / headers(可选对象)/ body(可选,提供时以 POST + JSON 发出)→ 原始 body 字符串
827
948
  async function proxyFetchText(proxyUrl, referer, extraHeaders, body) {
@@ -871,12 +992,23 @@ export function createEngine(env) {
871
992
  const VERSION_UPDATE_ANCHOR = { h: 4, mi: 0 };
872
993
 
873
994
  // 解析单个时间 → {ts, text};无法解析返回 {ts:null, text:null}
874
- function parseTime(s) {
995
+ // `tz`(可选)= 源站墙钟时区(见 15-env.js / 20-sources.js 的 `tz`)。
996
+ // 传了 → 绝对时刻按**源站时区**换算;不传 → 沿用本机时区(= 改造前行为)。
997
+ // **text 一律是源站墙钟原文**,不随 tz 变 —— 玩家看游戏内公告走的就是这串时间。
998
+ //
999
+ // 兜底:`sourceInstant` 定义在 15-env.js。若调用方只把本文件单独抽出来用
1000
+ // (回归脚本用 `new Function` 注入单个函数、或把 core 拆到别处),它可能不在作用域;
1001
+ // 那时退回 `new Date(...)`(本机时区)而不是抛 ReferenceError ——
1002
+ // 少一次时区换算,但绝不让"抽函数"这种用法直接崩掉。
1003
+ function parseTime(s, tz) {
1004
+ const mkTs = (y, mo, d, h, mi) => (typeof sourceInstant === "function"
1005
+ ? sourceInstant(y, mo, d, h, mi, tz)
1006
+ : new Date(y, mo - 1, d, h, mi).getTime());
875
1007
  const m = String(s).match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
876
1008
  if (m) {
877
1009
  const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]), h = Number(m[4]), mi = Number(m[5]);
878
1010
  const text = `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
879
- return { ts: new Date(y, mo - 1, d, h, mi).getTime(), text };
1011
+ return { ts: mkTs(y, mo, d, h, mi), text };
880
1012
  }
881
1013
  // 回退:`YYYY/MM/DD <版本更新后>`(日期 + 版本标签,缺时分)→ 按锚点时刻补全。
882
1014
  // 注意只认**带日期前缀**的这种;纯标签(`4.6版本更新后`)保持 null —— 那种确实
@@ -886,21 +1018,22 @@ export function createEngine(env) {
886
1018
  const y = Number(v[1]), mo = Number(v[2]), d = Number(v[3]);
887
1019
  const { h, mi } = VERSION_UPDATE_ANCHOR;
888
1020
  const text = `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
889
- return { ts: new Date(y, mo - 1, d, h, mi).getTime(), text };
1021
+ return { ts: mkTs(y, mo, d, h, mi), text };
890
1022
  }
891
1023
  return { ts: null, text: null };
892
1024
  }
893
1025
 
894
1026
  // 时间段 → startTs/endTs + 统一文本 mm-dd hh:mm ~ mm-dd hh:mm(无法解析的一侧保留原文)
895
- function parseRange(raw) {
1027
+ // `tz` 同 parseTime:源站墙钟时区(可选,不传 = 本机时区)
1028
+ function parseRange(raw, tz) {
896
1029
  const t = stripTags(raw);
897
1030
  const parts = t.split(/~/).map((x) => x.trim());
898
1031
  if (parts.length < 2) {
899
- const p = parseTime(parts[0]);
1032
+ const p = parseTime(parts[0], tz);
900
1033
  return { startTs: p.ts, endTs: null, startText: p.text, endText: null, raw: p.text ?? t };
901
1034
  }
902
- const a = parseTime(parts[0]);
903
- const b = parseTime(parts[1]);
1035
+ const a = parseTime(parts[0], tz);
1036
+ const b = parseTime(parts[1], tz);
904
1037
  return {
905
1038
  startTs: a.ts, endTs: b.ts,
906
1039
  startText: a.text ?? parts[0], endText: b.text ?? parts[1],
@@ -922,7 +1055,7 @@ export function createEngine(env) {
922
1055
  }
923
1056
 
924
1057
  // bwiki 通用:解析所有含「时间+版本」的卡池表(原神/星铁/绝区零)
925
- function parseAllBwiki(html) {
1058
+ function parseAllBwiki(html, tz) {
926
1059
  const out = [];
927
1060
  const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
928
1061
  for (const t of tables) {
@@ -942,7 +1075,7 @@ export function createEngine(env) {
942
1075
  const timeM = body.match(/<th[^>]*>\s*时间\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
943
1076
  const charM = body.match(/<th[^>]*>\s*(?:5星角色|S级代理人|6星干员|5星干员)\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
944
1077
  if (!timeM) continue;
945
- const range = parseRange(timeM[1]);
1078
+ const range = parseRange(timeM[1], tz);
946
1079
  out.push({
947
1080
  banner,
948
1081
  roles: charM ? stripTags(charM[1]) : "",
@@ -956,7 +1089,7 @@ export function createEngine(env) {
956
1089
  // 方舟:解析「干员轮换卡池」(标准寻访/当期轮换池)所有数据行。
957
1090
  // 该表行结构:序号 | 寻访页面(title=寻访模拟/干员轮换卡池N) | 开启时间 | 特定干员(6星) | 特定干员(5星)。
958
1091
  // 兼容历史「限时寻访」表(寻访页面|开启时间|特定干员6星|特定干员5星&4星)作为兜底。
959
- function parseArknights(html) {
1092
+ function parseArknights(html, tz) {
960
1093
  const out = [];
961
1094
  // 标准(干员轮换卡池N)+ 中坚(中坚甄选N / 中坚干员轮换卡池N):
962
1095
  // 行内 title="寻访模拟/<池名>"。**档位按池名判定且中坚优先**——「中坚干员轮换卡池74」
@@ -972,7 +1105,7 @@ export function createEngine(env) {
972
1105
  const tier = /中坚/.test(banner) ? "中坚" : "标准";
973
1106
  const roles = [...((tds[3] || "") + (tds[4] || "")).matchAll(/<a[^>]*title="([^"]+)"/g)]
974
1107
  .map((m) => m[1]).filter(Boolean);
975
- const range = parseRange(tds[2]);
1108
+ const range = parseRange(tds[2], tz);
976
1109
  out.push({ tier, banner, roles: roles.join("、"), ...range, isMain: true });
977
1110
  }
978
1111
  // 限时(联合行动/限定):解析「限时寻访」表
@@ -992,7 +1125,7 @@ export function createEngine(env) {
992
1125
  ? [...tds[2].matchAll(/<a[^>]*href="\/w\/[^"]*"[^>]*title="([^"]*)"/g)]
993
1126
  .map((m) => m[1].trim()).filter(Boolean).join("、")
994
1127
  : "";
995
- const range = parseRange(tds[1]);
1128
+ const range = parseRange(tds[1], tz);
996
1129
  out.push({ tier: "限时", banner, roles, ...range, isMain: true });
997
1130
  }
998
1131
  }
@@ -1003,8 +1136,8 @@ export function createEngine(env) {
1003
1136
  // 明日方舟当期卡池:**外显与悬停共用同一份"当期池"列表**(不再各挑一个)——
1004
1137
  // 外显按档位优先(限时 > 标准 > 中坚)、档内先结束者优先;悬停走统一的 buildPoolHover,
1005
1138
  // 与其它游戏同格式(每池『池名:角色』+ 时间行、同窗口合并时间、结束时间升序)。
1006
- function selectArknights(html, now = nowMs()) {
1007
- const items = parseArknights(html);
1139
+ function selectArknights(html, now = nowMs(), tz) {
1140
+ const items = parseArknights(html, tz);
1008
1141
  fillMissingStarts(items);
1009
1142
  const tiers = ["限时", "标准", "中坚"];
1010
1143
  const rank = (it) => {
@@ -1113,7 +1246,7 @@ export function createEngine(env) {
1113
1246
  // 方舟卡池默认抓取器:官方公告 CMS 优先(当期限时/联动寻访),
1114
1247
  // 官方无当期寻访公告(常规轮换周)或官方失败时自动回退 PRTS 卡池一览(逻辑同 selectArknights)。
1115
1248
  // 设置页手动切到 PRTS 备选源时则走 GACHA_FETCHERS["arknights-prts"],不经本函数。
1116
- async function fetchArknightsGacha(url, signal, now = nowMs()) {
1249
+ async function fetchArknightsGacha(url, signal, tz, now = nowMs()) {
1117
1250
  try {
1118
1251
  const official = await fetchArknightsOfficialPools(signal, now);
1119
1252
  if (official.length > 0) {
@@ -1143,7 +1276,7 @@ export function createEngine(env) {
1143
1276
  const json = await res.json();
1144
1277
  const html = json?.parse?.text;
1145
1278
  if (typeof html !== "string") throw new Error("bad-json");
1146
- const d = selectArknights(html, now);
1279
+ const d = selectArknights(html, now, tz);
1147
1280
  return d ? { ...d } : null;
1148
1281
  }
1149
1282
 
@@ -1216,21 +1349,33 @@ export function createEngine(env) {
1216
1349
  }
1217
1350
 
1218
1351
  // MM-DD HH:MM(同年窗口用)
1219
- function fmtMdHm(ts) {
1352
+ // 时间戳 → 显示文本。`tz`(可选)= **按该时区渲染**(源站时区)。
1353
+ // 为什么必须带 tz:绝对时刻已按源站时区换算,若文本仍按本机时区渲染,
1354
+ // 海外用户会看到"文本与时刻不一致"(例如源站写 09-30 04:00,却显示 09-29 20:00)。
1355
+ // 传 tz 后:**文本 = 源站墙钟**(与游戏内公告一致)、**时刻 = 正确绝对时刻**,两边统一。
1356
+ // 不传 tz 时行为与改造前完全一致(本机时区)。
1357
+ //
1358
+ // 兜底同 parseTime:`sourceWallParts` 在 15-env.js,单抽本文件时可能不在作用域。
1359
+ function wallOf(ts, tz) {
1360
+ if (typeof sourceWallParts === "function") return sourceWallParts(ts, tz);
1220
1361
  const d = new Date(ts);
1221
- return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1362
+ return { y: d.getFullYear(), mo: d.getMonth() + 1, d: d.getDate(), h: d.getHours(), mi: d.getMinutes() };
1363
+ }
1364
+ function fmtMdHm(ts, tz) {
1365
+ const w = wallOf(ts, tz);
1366
+ return `${String(w.mo).padStart(2, "0")}-${String(w.d).padStart(2, "0")} ${String(w.h).padStart(2, "0")}:${String(w.mi).padStart(2, "0")}`;
1222
1367
  }
1223
1368
 
1224
1369
  // YYYY-MM-DD HH:MM(跨年窗口用:避免"05-15 16:00 ~ 05-15 03:59"看着像结束早于开始)
1225
- function fmtYmdHm(ts) {
1226
- const d = new Date(ts);
1227
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1370
+ function fmtYmdHm(ts, tz) {
1371
+ const w = wallOf(ts, tz);
1372
+ return `${w.y}-${String(w.mo).padStart(2, "0")}-${String(w.d).padStart(2, "0")} ${String(w.h).padStart(2, "0")}:${String(w.mi).padStart(2, "0")}`;
1228
1373
  }
1229
1374
 
1230
1375
  // 窗口起止文本:两端同一年 → MM-DD;跨年 → 两端都带年份
1231
- function fmtWindow(startTs, endTs) {
1232
- const sameYear = new Date(startTs).getFullYear() === new Date(endTs).getFullYear();
1233
- return sameYear ? `${fmtMdHm(startTs)} ~ ${fmtMdHm(endTs)}` : `${fmtYmdHm(startTs)} ~ ${fmtYmdHm(endTs)}`;
1376
+ function fmtWindow(startTs, endTs, tz) {
1377
+ const sameYear = wallOf(startTs, tz).y === wallOf(endTs, tz).y;
1378
+ return sameYear ? `${fmtMdHm(startTs, tz)} ~ ${fmtMdHm(endTs, tz)}` : `${fmtYmdHm(startTs, tz)} ~ ${fmtYmdHm(endTs, tz)}`;
1234
1379
  }
1235
1380
 
1236
1381
  // 长期/常驻玩法判定:声明窗口超过该天数的不当作"当期活动"(外显与悬停共用,①)。
@@ -1371,7 +1516,7 @@ export function createEngine(env) {
1371
1516
  // 每一组 = 该组 data-start/data-end 之后、到下一组之前的那段里的「共鸣者/xxx」。
1372
1517
  // 旧实现取页面第一组时间 + 整页前 6 个角色名 → 备选/兜底源会显示"过期档期 + 跨池混入的角色",
1373
1518
  // 且不报任何失败(实测该页当前只有一组已过期计时器)。没有覆盖当前的组 → 返回 null(未公布)。
1374
- function parseWuwaPool(html, now = nowMs()) {
1519
+ function parseWuwaPool(html, now = nowMs(), tz) {
1375
1520
  const text = String(html || "");
1376
1521
  const marks = [...text.matchAll(/data-start="([^"]+)"\s+data-end="([^"]+)"/g)];
1377
1522
  // 页面拿到了却一个计时器都没有 → 汇总页改版(抛错让面板显示"卡池失败"),
@@ -1379,8 +1524,8 @@ export function createEngine(env) {
1379
1524
  if (marks.length === 0) throw new Error("wuwa-pool-no-timer");
1380
1525
  for (let i = 0; i < marks.length; i++) {
1381
1526
  const m = marks[i];
1382
- const start = parseTime(m[1]);
1383
- const end = parseTime(m[2]);
1527
+ const start = parseTime(m[1], tz);
1528
+ const end = parseTime(m[2], tz);
1384
1529
  if (start.ts == null || end.ts == null) continue;
1385
1530
  if (!(start.ts <= now && now <= end.ts)) continue;
1386
1531
  const seg = text.slice(m.index + m[0].length, i + 1 < marks.length ? marks[i + 1].index : text.length);
@@ -1403,7 +1548,7 @@ export function createEngine(env) {
1403
1548
  // 起始端是**版本标签**而非绝对日期,旧实现用 `(\d{4})年(\d{1,2})月…` 匹配整段 → 恒失败 →
1404
1549
  // 卡池侧返回 null(2026-10-01 实测:官方 JSON 已带 3 个在开角色池,却显示"未公布")。
1405
1550
  // 现在源站直接给了时间戳,比解析正文更准,也不受措辞漂移影响。
1406
- function parseWuwaNotice(list, now = nowMs()) {
1551
+ function parseWuwaNotice(list, now = nowMs(), tz) {
1407
1552
  const groups = [list?.game, list?.activity, list?.recommend].filter(Array.isArray);
1408
1553
  const stripH = (s) => String(s || "")
1409
1554
  .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
@@ -1452,7 +1597,7 @@ export function createEngine(env) {
1452
1597
  const d = new Date(ts);
1453
1598
  return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1454
1599
  };
1455
- const raw = fmtWindow(first.startTs, first.endTs);
1600
+ const raw = fmtWindow(first.startTs, first.endTs, tz);
1456
1601
  // 悬停:首行=卡池类型(与卡片外显同源),随后每池"池名:角色"一行;窗口相同则时间只在末尾写一遍;
1457
1602
  // 按结束时间升序。**类型行只在本源补**,不动全站共用的 buildPoolHover(它按约定对 0/1 池返回 "")。
1458
1603
  const poolHover = buildPoolHover(cur.map((p) => ({
@@ -1472,8 +1617,8 @@ export function createEngine(env) {
1472
1617
  // 鸣潮官方活动解析:同一份全量公告的 `recommend` 组里,`tag === 7` 是卡池、**`tag === 5` 是限时活动**。
1473
1618
  // 活动条目形如 tabTitle="[团团勇者大乱斗]休闲活动",同样带绝对时间戳。
1474
1619
  // 这是 2026-10-01 起鸣潮活动的**默认源**——Bwiki 活动日历页已停更(最新一条结束于 2026/9/29),
1475
- // 而官方源有当期 3.7 的活动,且与卡池同源、同一次请求即可拿到两侧数据。
1476
- function parseWuwaRecommendEvents(list, now = nowMs()) {
1620
+ // 而官方源有当期 3.7 的活动,且**与卡池是同一条 URL**、同一次请求即可拿到两侧数据。
1621
+ function parseWuwaRecommendEvents(list, now = nowMs(), tz) {
1477
1622
  const arr = Array.isArray(list?.recommend) ? list.recommend : [];
1478
1623
  const stripH = (s) => String(s || "")
1479
1624
  .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
@@ -1493,13 +1638,13 @@ export function createEngine(env) {
1493
1638
  const sorted = sortEventItems(events);
1494
1639
  if (sorted.length === 0) return null;
1495
1640
  const primary = pickEventPrimary(sorted) || sorted[0];
1496
- const dates = fmtWindow(primary.startTs, primary.endTs);
1641
+ const dates = fmtWindow(primary.startTs, primary.endTs, tz);
1497
1642
  return { banner: primary.name, bannerDates: dates, bannerDatesRaw: dates, eventHover: buildEventHover(sorted) };
1498
1643
  }
1499
1644
 
1500
1645
  // 鸣潮卡池默认抓取器:官方公告(entrypoint → 目录 → zh-Hans.json 全量)优先;
1501
1646
  // 无当期公告 / 抓取失败 → 自动回退 Bwiki 角色轮换池(逻辑同 parseWuwaPool)
1502
- async function fetchWuwaGacha(entryUrl, signal, now = nowMs()) {
1647
+ async function fetchWuwaGacha(entryUrl, signal, tz, now = nowMs()) {
1503
1648
  try {
1504
1649
  const ref = "https://aki-gm-resources.aki-game.com/";
1505
1650
  const ej = await proxyFetchJson(entryUrl, ref);
@@ -1509,16 +1654,16 @@ export function createEngine(env) {
1509
1654
  try { list = await proxyFetchJson(dir + "zh-Hans.json", ref); } catch { list = null; }
1510
1655
  if (!list || typeof list !== "object") list = await proxyFetchJson(dir + "notice.json", ref);
1511
1656
  // **同一份 JSON 里卡池(tag=7)与活动(tag=5)都在** → 顺带把活动字段也返回。
1512
- // 这是"统一来源注册表"里写明的同址复用契约:卡池载荷带 event 字段时,
1513
- // refresh 不再为活动侧另抓一次(否则同址条目每轮多一个请求)。
1657
+ // 这是"统一来源注册表"里写明的复用契约:**两侧是同一条 URL** 且卡池载荷带 event 字段时,
1658
+ // refresh 不再为活动侧另抓一次(否则每轮会向同一条 URL 重复发一次请求)。
1514
1659
  // ⚠️ 我重写解析时一度只返回卡池字段,导致鸣潮每轮请求从 1 次变成 2 次 —— 已补回。
1515
1660
  // 活动侧仍保留自己的抓取器(fetchWuwaEventsOfficial):卡池侧失败、或用户在设置里
1516
1661
  // 单独选活动来源时,活动侧要能自己抓、自己报错(解耦不变)。
1517
- const ev = parseWuwaRecommendEvents(list, now);
1662
+ const ev = parseWuwaRecommendEvents(list, now, tz);
1518
1663
  const evFields = ev
1519
1664
  ? { event: ev.banner, eventDates: ev.bannerDates || "", eventDatesRaw: ev.bannerDatesRaw || ev.bannerDates || "", eventHover: ev.eventHover || "" }
1520
1665
  : {};
1521
- const d = parseWuwaNotice(list, now);
1666
+ const d = parseWuwaNotice(list, now, tz);
1522
1667
  if (d) return { ...d, ...evFields };
1523
1668
  } catch { /* 官方失败 → Bwiki 备选 */ }
1524
1669
  const apiUrl = WUWA_BWIKI_URL + (WUWA_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
@@ -1527,19 +1672,19 @@ export function createEngine(env) {
1527
1672
  const json = await res.json();
1528
1673
  const html = json?.parse?.text;
1529
1674
  if (typeof html !== "string") throw new Error("bad-json");
1530
- return parseWuwaPool(html);
1675
+ return parseWuwaPool(html, nowMs(), tz);
1531
1676
  }
1532
1677
 
1533
1678
  // 鸣潮活动默认抓取器:抓同一份官方公告,取 `recommend` 组里 tag=5 的限时活动。
1534
- // 与卡池侧同一 URL(entrypoint.json),所以 refresh 的同址复用会让两侧共用一次请求。
1535
- async function fetchWuwaEventsOfficial(entryUrl) {
1679
+ // 与卡池侧是**同一条 URL**(entrypoint.json),所以 refresh 的复用会让两侧共用一次请求。
1680
+ async function fetchWuwaEventsOfficial(entryUrl, _signal, tz) {
1536
1681
  const ref = "https://aki-gm-resources.aki-game.com/";
1537
1682
  const ej = await proxyFetchJson(entryUrl, ref);
1538
1683
  const contentUrl = Array.isArray(ej?.contentUrl) ? ej.contentUrl[0] : "";
1539
1684
  const dir = contentUrl ? contentUrl.replace(/[^/]*$/, "") : String(entryUrl).replace(/[^/]*$/, "");
1540
1685
  const list = await proxyFetchJson(dir + "zh-Hans.json", ref);
1541
1686
  if (!list || typeof list !== "object") throw new Error("wuwa-event-bad-json");
1542
- const d = parseWuwaRecommendEvents(list, nowMs());
1687
+ const d = parseWuwaRecommendEvents(list, nowMs(), tz);
1543
1688
  if (!d) return null;
1544
1689
  return {
1545
1690
  event: d.banner,
@@ -1553,7 +1698,7 @@ export function createEngine(env) {
1553
1698
  // 公告形如:sIntro="本期代理人与音擎调频活动时间为:3.2版本更新后 ~ 2026/09/30 11:59",
1554
1699
  // sContent 内含「活动期间,限定S级代理人[克拉蕾(电·锋御)]、[南宫羽(以太·击破)]…」。
1555
1700
  // 起点为"版本更新后"时,用同版本「更新公告」的 dtStartTime 补全;「独家重映/音擎回响」自选段跳过。
1556
- function parseZzzFreq(payload, now = nowMs()) {
1701
+ function parseZzzFreq(payload, now = nowMs(), tz) {
1557
1702
  const list = Array.isArray(payload?.data?.list) ? payload.data.list : [];
1558
1703
  const clean = (s) => stripTags(s);
1559
1704
  // 版本更新公告 → "X.Y版本更新后"的起点;同时抽取「S级代理人[X] → 「Y」频段」对应表
@@ -1568,7 +1713,7 @@ export function createEngine(env) {
1568
1713
  const vm = t.match(/(\d+\.\d+)\s*版本/);
1569
1714
  if (!vm) continue;
1570
1715
  if (/更新(?:公告|通知)/.test(t) && !/预下载|预约|前瞻|预抽/.test(t)) {
1571
- const p = parseTime(it.dtStartTime);
1716
+ const p = parseTime(it.dtStartTime, tz);
1572
1717
  if (p.ts != null && verStart[vm[1]] == null) verStart[vm[1]] = p;
1573
1718
  }
1574
1719
  const text = clean(it.sIntro) + " " + clean(it.sContent);
@@ -1598,7 +1743,7 @@ export function createEngine(env) {
1598
1743
  while ((m = re.exec(text)) !== null) {
1599
1744
  marks.push({ start: m[1], end: m[2], from: m.index, to: m.index + m[0].length, after: /版本更新后/.test(m[1]) });
1600
1745
  }
1601
- const created = parseTime(it.dtCreateTime).ts ?? 0;
1746
+ const created = parseTime(it.dtCreateTime, tz).ts ?? 0;
1602
1747
  for (let i = 0; i < marks.length; i++) {
1603
1748
  const seg = text.slice(marks[i].to, i + 1 < marks.length ? marks[i + 1].from : text.length);
1604
1749
  // 该时间窗对应的「限定S级代理人」句(排除独家重映/音擎回响的自选说明)
@@ -1608,8 +1753,8 @@ export function createEngine(env) {
1608
1753
  if (!roleM) continue;
1609
1754
  const roles = [...roleM[1].matchAll(/[\[【]([^\]】]+)[\]】]/g)].map((x) => normRole(x[1].trim())).join("、");
1610
1755
  if (!roles) continue;
1611
- const sp = marks[i].after ? (verStart[ver] || { ts: null, text: null }) : parseTime(marks[i].start);
1612
- const ep = parseTime(marks[i].end);
1756
+ const sp = marks[i].after ? (verStart[ver] || { ts: null, text: null }) : parseTime(marks[i].start, tz);
1757
+ const ep = parseTime(marks[i].end, tz);
1613
1758
  if (ep.ts == null) continue;
1614
1759
  pools.push({
1615
1760
  ver, part, roles,
@@ -1647,11 +1792,21 @@ export function createEngine(env) {
1647
1792
  }
1648
1793
 
1649
1794
  // 绝区零卡池默认抓取器:官网公告优先;无当期频段公告 / 抓取失败 → 自动回退 Bwiki 往期调频
1650
- async function fetchZzzGacha(listUrl, signal, now = nowMs()) {
1795
+ async function fetchZzzGacha(listUrl, signal, tz, now = nowMs()) {
1651
1796
  try {
1652
1797
  const payload = await proxyFetchJson(listUrl, "https://zzz.mihoyo.com/");
1653
- const d = parseZzzFreq(payload, now);
1654
- if (d) return d;
1798
+ // **同一份 payload 里活动数据也在**(该接口同时含频段公告与「活动说明」公告)→ 顺带返回活动字段。
1799
+ // 这是"统一来源注册表"里写明的复用契约:卡池载荷带 event 字段时,refresh 不再为活动侧
1800
+ // 另抓一次。绝区零两侧是**同一条 URL**(同一 iChanId=279),所以此前每轮会向它发 2 次请求:
1801
+ // 一次 parseZzzFreq 取频段、一次 parseZzzEventsOfficial 取活动 —— 纯重复(2026-10-01 实测)。
1802
+ // 活动侧**仍保留**自己的抓取器(fetchZzzEventsOfficial 与 `zzz-event-bwiki` 备选):
1803
+ // 卡池侧失败、或用户在设置里单独选活动来源时,活动侧要能自己抓、自己报错(解耦不变)。
1804
+ const ev = parseZzzEventsOfficial(payload, now, tz);
1805
+ const evFields = ev
1806
+ ? { event: ev.event, eventDates: ev.eventDates || "", eventDatesRaw: ev.eventDatesRaw || "", eventHover: ev.eventHover || "" }
1807
+ : {};
1808
+ const d = parseZzzFreq(payload, now, tz);
1809
+ if (d) return { ...d, ...evFields };
1655
1810
  } catch { /* 官方失败 → Bwiki 备选 */ }
1656
1811
  const apiUrl = ZZZ_BWIKI_URL + (ZZZ_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
1657
1812
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
@@ -1670,7 +1825,7 @@ export function createEngine(env) {
1670
1825
  // 由 sortEventItems / pickEventPrimary 的既有规则自然沉到外显与悬停的最后,不跳过、不丢弃。
1671
1826
  const ZZZ_EVENT_WINDOW_RE = /((?:\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}\s+\d{1,2}:\d{2})|(?:\d+\.\d+\s*版本更新后))\s*[~~\-—]\s*((?:\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}\s+\d{1,2}:\d{2})|(?:\d+\.\d+\s*版本结束))/g;
1672
1827
 
1673
- function parseZzzEventsOfficial(payload, now = nowMs()) {
1828
+ function parseZzzEventsOfficial(payload, now = nowMs(), tz) {
1674
1829
  const list = Array.isArray(payload?.data?.list) ? payload.data.list : [];
1675
1830
  const clean = (s) => stripTags(s);
1676
1831
  // 版本起点表:取该版本的「更新公告」发布时间。
@@ -1682,7 +1837,7 @@ export function createEngine(env) {
1682
1837
  const vm = title.match(/(\d+\.\d+)\s*版本/);
1683
1838
  if (!vm || !/更新(?:公告|通知)/.test(title)) continue;
1684
1839
  if (/预下载|预约|前瞻|预抽/.test(title)) continue;
1685
- const ts = parseTime(it.dtStartTime).ts;
1840
+ const ts = parseTime(it.dtStartTime, tz).ts;
1686
1841
  if (ts != null && verStart[vm[1]] == null) verStart[vm[1]] = ts;
1687
1842
  }
1688
1843
  const versions = Object.keys(verStart).sort((a, b) => verStart[a] - verStart[b]);
@@ -1697,7 +1852,7 @@ export function createEngine(env) {
1697
1852
  if (verStart[sv[1]] == null) return null; // 该版本尚未开始 → 活动还没上线
1698
1853
  startTs = verStart[sv[1]];
1699
1854
  } else {
1700
- const p = parseTime(startText);
1855
+ const p = parseTime(startText, tz);
1701
1856
  if (p.ts == null) return null;
1702
1857
  startTs = p.ts;
1703
1858
  }
@@ -1709,7 +1864,7 @@ export function createEngine(env) {
1709
1864
  const later = versions.find((v) => verStart[v] > verStart[ev[1]]);
1710
1865
  if (later != null) endTs = verStart[later] - 60000;
1711
1866
  } else {
1712
- const p = parseTime(endText);
1867
+ const p = parseTime(endText, tz);
1713
1868
  if (p.ts == null) return null;
1714
1869
  endTs = p.ts;
1715
1870
  }
@@ -1734,7 +1889,7 @@ export function createEngine(env) {
1734
1889
  const active = sortEventItems([...byName.values()]);
1735
1890
  if (active.length === 0) return null;
1736
1891
  const primary = pickEventPrimary(active) || active[0];
1737
- const dates = primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || "");
1892
+ const dates = primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs, tz) : (primary.raw || "");
1738
1893
  return {
1739
1894
  event: primary.name,
1740
1895
  eventDates: dates,
@@ -1744,14 +1899,14 @@ export function createEngine(env) {
1744
1899
  };
1745
1900
  }
1746
1901
 
1747
- async function fetchZzzEventsOfficial(listUrl, signal) {
1902
+ async function fetchZzzEventsOfficial(listUrl, signal, tz) {
1748
1903
  const payload = await proxyFetchJson(listUrl, "https://zzz.mihoyo.com/");
1749
- return parseZzzEventsOfficial(payload);
1904
+ return parseZzzEventsOfficial(payload, nowMs(), tz);
1750
1905
  }
1751
1906
 
1752
1907
  // 鸣潮:活动日历页 → font-size:17px 标题 + font-size:11px 时间,选当期
1753
1908
  // 注意:复用 selectCurrent 需要 isMain 字段(该函数按 isMain 过滤主池)
1754
- function parseWuwaCalendar(html) {
1909
+ function parseWuwaCalendar(html, tz) {
1755
1910
  const items = [];
1756
1911
  const re = /font-size:17px[^>]*>\s*<p>\s*([\s\S]*?)\s*<\/p>([\s\S]*?)(?=font-size:17px|$)/g;
1757
1912
  let m;
@@ -1761,7 +1916,7 @@ export function createEngine(env) {
1761
1916
  const timeM = m[2].match(/font-size:11px[^>]*>\s*<p>\s*([\s\S]*?)\s*<\/p>/);
1762
1917
  const timeText = timeM ? stripTags(timeM[1]) : "";
1763
1918
  if (!/20\d{2}\//.test(timeText)) continue;
1764
- const range = parseRange(timeText);
1919
+ const range = parseRange(timeText, tz);
1765
1920
  items.push({ banner: name, name, ...range, isMain: true });
1766
1921
  }
1767
1922
  const now = nowMs();
@@ -1771,7 +1926,7 @@ export function createEngine(env) {
1771
1926
  if (active.length === 0) return null;
1772
1927
  // 外显:类别优先(战斗/高难类优先),同级内结束时间升序(③);悬停仍按 endTs 升序全量
1773
1928
  const primary = pickEventPrimary(active) || active[0];
1774
- const dates = primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || "");
1929
+ const dates = primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs, tz) : (primary.raw || "");
1775
1930
  return { banner: primary.name, roles: "", bannerDates: dates, bannerDatesRaw: primary.raw || dates, eventHover: buildEventHover(active) };
1776
1931
  }
1777
1932
 
@@ -1899,12 +2054,14 @@ export function createEngine(env) {
1899
2054
  // 返回值三态:数据对象 / null(**结构在**但没有覆盖当前时刻的期次 → 未公布)/ undefined
1900
2055
  // (这份 JS 里**根本没有**卡池数据结构 → 交给调用方决定:多 chunk 时继续找下一个,
1901
2056
  // 全部 chunk 都没有则说明页面改版 → 报错,而不是伪装成"未公布")。
1902
- function currentFromCanmoe(js, now) {
2057
+ function currentFromCanmoe(js, now, tz) {
1903
2058
  now = now || nowMs();
1904
2059
  let sawStructure = false;
1905
2060
  const fmt = (iso) => {
1906
- const d = new Date(iso);
1907
- return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
2061
+ // 按**源站时区**(`tz`)渲染,而不是本机时区:否则海外用户会看到
2062
+ // 与 `startTs/endTs`(已按源站时区换算)不一致的钟点。
2063
+ const w = sourceWallParts(new Date(iso).getTime(), tz);
2064
+ return `${String(w.mo).padStart(2, "0")}-${String(w.d).padStart(2, "0")} ${String(w.h).padStart(2, "0")}:${String(w.mi).padStart(2, "0")}`;
1908
2065
  };
1909
2066
  // **统一规则(§49)**:当前时刻落在持续期间内的卡池**全部合并**外显。
1910
2067
  // 旧实现是"两个分支各自命中第一个就 return" → 只显示一个池,同期的另一个池看不到
@@ -2003,8 +2160,8 @@ export function createEngine(env) {
2003
2160
  // 兼容旧调用:parseCanmoe / parseCanmoeLoose 均走统一的"当期选择"逻辑(now 可注入)。
2004
2161
  // 这两个是**给通用解析(自定义条目/自定义地址)用的宽松包装**:把 undefined 归一成 null,
2005
2162
  // 即"读不出来 → 未公布",不在这里抛错(用户自定义地址读不出内容是常态,不该报成源站故障)。
2006
- function parseCanmoe(js, now = nowMs()) { const d = currentFromCanmoe(js, now); return d === void 0 ? null : d; }
2007
- function parseCanmoeLoose(js, now = nowMs()) { return parseCanmoe(js, now); }
2163
+ function parseCanmoe(js, now = nowMs(), tz) { const d = currentFromCanmoe(js, now, tz); return d === void 0 ? null : d; }
2164
+ function parseCanmoeLoose(js, now = nowMs(), tz) { return parseCanmoe(js, now, tz); }
2008
2165
 
2009
2166
  // 终末地(canmoe 经 host 代理):页面 HTML → 定位 BannerCalendar chunk → 抓 chunk JS → 窗口匹配当期
2010
2167
  // canmoe 无 CORS 头,两步都经 host 代理(referer 用页面 origin 满足反爬)
@@ -2014,7 +2171,7 @@ export function createEngine(env) {
2014
2171
  // · 有覆盖当前时刻的期次 → 返回数据;
2015
2172
  // · 拿到 JS 且里面有卡池结构、但没有覆盖当前的期次 → return null(未公布);
2016
2173
  // · 页面/所有 chunk 里都找不到卡池结构(或 chunk 全抓失败)→ **抛错**(面板显示"卡池失败")。
2017
- async function fetchCanmoeEndfield(pageUrl, now = nowMs()) {
2174
+ async function fetchCanmoeEndfield(pageUrl, _signal, tz, now = nowMs()) {
2018
2175
  const html = await proxyFetchText(pageUrl, "https://end.canmoe.com/");
2019
2176
  const chunks = nextJsChunkUrls(html, "BannerCalendar", pageUrl);
2020
2177
  if (chunks.length === 0) throw new Error("canmoe-no-chunk"); // 页面拿到了但没有数据块链接 = 改版
@@ -2028,7 +2185,7 @@ export function createEngine(env) {
2028
2185
  continue;
2029
2186
  }
2030
2187
  fetchedAny = true;
2031
- const d = currentFromCanmoe(js, now);
2188
+ const d = currentFromCanmoe(js, now, tz);
2032
2189
  if (d === void 0) continue; // 这份 chunk 里没有卡池结构 → 看下一个
2033
2190
  sawStructure = true;
2034
2191
  if (d) {
@@ -2109,7 +2266,7 @@ export function createEngine(env) {
2109
2266
  }
2110
2267
 
2111
2268
  // 异环(ldshop 经 host 代理):抓页面 → 解析卡池表 → 选当期
2112
- async function fetchLdshopNte(pageUrl) {
2269
+ async function fetchLdshopNte(pageUrl, _signal, tz) {
2113
2270
  const html = await proxyFetchText(pageUrl, "https://www.ldshop.gg/");
2114
2271
  const pools = parseLdshopPools(html);
2115
2272
  if (pools.length === 0) return null;
@@ -2143,7 +2300,7 @@ export function createEngine(env) {
2143
2300
  // 取第一篇能解析出当期卡池/活动的正文;"不停服更新"不含新卡池,跳过。
2144
2301
  // 三态:有当期内容 → 数据;列表页有公告但都不含当期内容(或"不停服更新")→ null(未公布);
2145
2302
  // 列表页**一条公告链接都没有** → 抛错(官网列表改版,让面板显示"卡池失败"而不是"未公布")。
2146
- async function fetchNteWanmei(listUrl, signal) {
2303
+ async function fetchNteWanmei(listUrl, signal, tz) {
2147
2304
  const ref = "https://yh.wanmei.com/";
2148
2305
  const seen = new Set();
2149
2306
  const queue = [listUrl];
@@ -2160,7 +2317,7 @@ export function createEngine(env) {
2160
2317
  if (!NTE_MAINT_RE.test(m[2]) || /不停服/.test(m[2])) continue;
2161
2318
  if (details >= NTE_MAX_DETAILS) return null; // 试读额度用完(此时必然已见到公告链接)
2162
2319
  details++;
2163
- const data = parseNteWanmei(await proxyFetchText("https://yh.wanmei.com" + m[1], ref));
2320
+ const data = parseNteWanmei(await proxyFetchText("https://yh.wanmei.com" + m[1], ref), tz);
2164
2321
  if (data) return data;
2165
2322
  }
2166
2323
  for (const u of nteNextPageUrls(listUrl, html, seen)) if (!queue.includes(u)) queue.push(u);
@@ -2227,7 +2384,7 @@ export function createEngine(env) {
2227
2384
  }
2228
2385
 
2229
2386
  // 解析官网公告正文 → 当期卡池(有「棋盘说明」的角色卡池,按**统一规则**合并)+ 当期活动(限时活动)
2230
- function parseNteWanmei(html) {
2387
+ function parseNteWanmei(html, tz) {
2231
2388
  const text = String(html || "")
2232
2389
  .replace(/<script[\s\S]*?<\/script>/gi, " ")
2233
2390
  .replace(/<style[\s\S]*?<\/style>/gi, " ")
@@ -2283,10 +2440,10 @@ export function createEngine(env) {
2283
2440
  // 依次尝试 bwiki 式「时间+版本」表、方舟式「限时寻访」表、GachaTracker 式日期表、
2284
2441
  // Next.js SPA(canmoe 等,页面无表格、数据在组件 chunk 里),选当期;
2285
2442
  // 全部失败返回 null(调用方按解析失败处理,不做可达性健康检查)
2286
- async function tryParseGenericGacha(url, signal) {
2443
+ async function tryParseGenericGacha(url, signal, tz) {
2287
2444
  const html = await fetchHtmlText(url, signal);
2288
2445
  const now = nowMs();
2289
- const cur = selectCurrent(parseAllBwiki(html), now) || selectCurrent(parseArknights(html), now);
2446
+ const cur = selectCurrent(parseAllBwiki(html, tz), now) || selectCurrent(parseArknights(html, tz), now);
2290
2447
  if (cur && cur.banner && cur.bannerDates) return cur;
2291
2448
  const gt = parseGachaTracker(html);
2292
2449
  if (gt && gt.banner && gt.bannerDates) return gt;
@@ -2307,7 +2464,7 @@ export function createEngine(env) {
2307
2464
 
2308
2465
  // 通用活动解析:扫描含「时间」(或「活动时间」)表头的表格,行内找时间与名称列,选当期
2309
2466
  // 起始为"版本更新后"等无日期文本时保留 startTs=null,由 selectCurrent 的 fillMissingStarts 补全
2310
- function collectGenericEvents(html) {
2467
+ function collectGenericEvents(html, tz) {
2311
2468
  const items = [];
2312
2469
  const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
2313
2470
  for (const t of tables) {
@@ -2331,7 +2488,7 @@ export function createEngine(env) {
2331
2488
  // 名字里偶发混进图片文件名残渣(如「文件:巡星之礼第二十六期.png 」)→ 去掉该前缀
2332
2489
  name = name.replace(/^文件:[^\s]*?\.(?:png|jpe?g|gif|webp|svg)\s*/i, "").trim();
2333
2490
  if (!name) continue;
2334
- const range = parseRange(time);
2491
+ const range = parseRange(time, tz);
2335
2492
  // 起始可为 null("版本更新后"),结束时间必须有效——**除非这行是永久活动**。
2336
2493
  // 原来这里一律 `endTs == null → continue`,把「…~永久」的行在采集阶段就丢了,
2337
2494
  // 下游连"它存在过"都看不到(表现为源站表里有、面板悬停里没有)。
@@ -2347,14 +2504,14 @@ export function createEngine(env) {
2347
2504
  }
2348
2505
 
2349
2506
  // 当期活动(外显取 selectCurrent 的那条,行为同旧版)
2350
- function parseGenericEvents(html) {
2351
- return selectCurrent(collectGenericEvents(html), nowMs());
2507
+ function parseGenericEvents(html, tz) {
2508
+ return selectCurrent(collectGenericEvents(html, tz), nowMs());
2352
2509
  }
2353
2510
 
2354
2511
  // Bwiki 卡池列载荷(原神/星铁):外显沿用 selectCurrent(同窗口主池角色合并、武器/光锥池不入选);
2355
2512
  // bannerHover 列出同期全部主池(每池"池名:角色"+时间;窗口相同则合并时间;结束时间升序)
2356
- function bwikiGachaPayload(html) {
2357
- const items = parseAllBwiki(html);
2513
+ function bwikiGachaPayload(html, tz) {
2514
+ const items = parseAllBwiki(html, tz);
2358
2515
  // 页面拿到了却连一行候选都没有 → wiki 表结构变了(抛错,面板显示"卡池失败");
2359
2516
  // 有候选但都不覆盖当前时刻 → 下面返回 null(未公布)。这两件事必须分开。
2360
2517
  if (items.length === 0) throw new Error("bwiki-gacha-no-table");
@@ -2379,8 +2536,8 @@ export function createEngine(env) {
2379
2536
  // 注意 selectCurrent 会就地补全缺失起点(fillMissingStarts),故这里只取快照自行排序,
2380
2537
  // 避免"版本更新后 ~ 未来"这类起点未给的行被补成未来起点而漏掉。
2381
2538
  // 只有 1 条时 buildEventHover 返回 "",由 UI 退回单条展示(兜底)。
2382
- function genericEventPayload(html) {
2383
- const items = collectGenericEvents(html);
2539
+ function genericEventPayload(html, tz) {
2540
+ const items = collectGenericEvents(html, tz);
2384
2541
  // 页面拿到了却连一行候选都没有 → 活动表结构变了(抛错 = "活动失败");
2385
2542
  // 有候选但当期没有覆盖现在的 → 下面返回 null(未公布)
2386
2543
  if (items.length === 0) throw new Error("bwiki-event-no-table");
@@ -2395,7 +2552,7 @@ export function createEngine(env) {
2395
2552
  const primary = pickEventPrimary(active) || active[0];
2396
2553
  return {
2397
2554
  event: primary.name,
2398
- eventDates: primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || ""),
2555
+ eventDates: primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs, tz) : (primary.raw || ""),
2399
2556
  eventDatesRaw: primary.raw || "",
2400
2557
  eventHover: buildEventHover(active, permanent.length)
2401
2558
  };
@@ -2406,7 +2563,7 @@ export function createEngine(env) {
2406
2563
  // 注意 data-time 第一个值是页面缓存时刻(非开始时间),故开始以文本列为准。
2407
2564
  // 活动名带核心分类前缀("支线故事:墟·复刻"):分类取第三列 <a title="分类:XXX"> 链接,
2408
2565
  // 核心分类 = 排除"复刻活动"(修饰词)后的第一个。
2409
- function collectPrtsEvents(html) {
2566
+ function collectPrtsEvents(html, tz) {
2410
2567
  const items = [];
2411
2568
  const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
2412
2569
  for (const t of tables) {
@@ -2435,7 +2592,7 @@ export function createEngine(env) {
2435
2592
  }
2436
2593
  const label = coreCat ? `${coreCat}\uFF1A${name}` : name;
2437
2594
  // 开始:第一列文本("2026-08-22 04:00")
2438
- const st = parseTime(stripTags(tds[0]).trim());
2595
+ const st = parseTime(stripTags(tds[0]).trim(), tz);
2439
2596
  items.push({
2440
2597
  banner: label,
2441
2598
  cat: coreCat,
@@ -2448,13 +2605,13 @@ export function createEngine(env) {
2448
2605
  }
2449
2606
 
2450
2607
  // 当期活动(外显取 selectCurrent 的那条,行为同旧版)
2451
- function parsePrtsEvents(html) {
2452
- return selectCurrent(collectPrtsEvents(html), nowMs());
2608
+ function parsePrtsEvents(html, tz) {
2609
+ return selectCurrent(collectPrtsEvents(html, tz), nowMs());
2453
2610
  }
2454
2611
 
2455
2612
  // 活动列载荷:外显=排序第一条(最快结束的当期活动,③);eventHover=全部覆盖当前时刻的活动(同序)
2456
- function prtsEventPayload(html) {
2457
- const items = collectPrtsEvents(html);
2613
+ function prtsEventPayload(html, tz) {
2614
+ const items = collectPrtsEvents(html, tz);
2458
2615
  const snapshot = items.map((it) => ({ name: it.banner, cat: it.cat || "", startTs: it.startTs, endTs: it.endTs, raw: it.raw }));
2459
2616
  const now = nowMs();
2460
2617
  const active = sortEventItems(snapshot.filter((it) => it.endTs != null && it.endTs >= now && (it.startTs == null || it.startTs <= now)));
@@ -2463,7 +2620,7 @@ export function createEngine(env) {
2463
2620
  const primary = pickEventPrimary(active) || active[0];
2464
2621
  return {
2465
2622
  event: primary.name,
2466
- eventDates: primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || ""),
2623
+ eventDates: primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs, tz) : (primary.raw || ""),
2467
2624
  eventDatesRaw: primary.raw || "",
2468
2625
  eventHover: buildEventHover(active)
2469
2626
  };
@@ -2488,7 +2645,7 @@ export function createEngine(env) {
2488
2645
  // <a class="a-link" href="...">Bedazzling Dawnstar Sign-In</a><br>(Version 1.4)<br>08/09/26 - 09/02/26
2489
2646
  // 日期为美式 MM/DD/YY;多个并行当期活动时选"结束最晚"(覆盖全部当期窗口)。
2490
2647
  // 无起止区间(只有开始日,如 "07/16")的条目跳过。
2491
- function parseGame8Events(html) {
2648
+ function parseGame8Events(html, tz) {
2492
2649
  const items = [];
2493
2650
  for (const m of html.matchAll(/<a class="a-link"[^>]*>([^<]+)<\/a><br>\((Version[^)]*)\)<br>([^<]*)/g)) {
2494
2651
  const period = m[3].trim();
@@ -2527,7 +2684,7 @@ export function createEngine(env) {
2527
2684
  }
2528
2685
 
2529
2686
  // parseGame8Events 的宽松回退:容忍 <a> 属性顺序/空白变化。仅在主解析未命中时使用。
2530
- function parseGame8EventsLoose(html) {
2687
+ function parseGame8EventsLoose(html, tz) {
2531
2688
  const items = [];
2532
2689
  for (const m of html.matchAll(/<a[^>]*>\s*([^<]+?)\s*<\/a>[\s\S]*?\(Version[^)]*\)[\s\S]*?(\d{1,2}\/\d{1,2}\/\d{2,4}\s*-\s*\d{1,2}\/\d{1,2}\/\d{2,4})/g)) {
2533
2690
  const period = m[2].trim();
@@ -2556,9 +2713,9 @@ export function createEngine(env) {
2556
2713
  }
2557
2714
 
2558
2715
  // 终末地(Game8 经 host 代理,无 CORS):活动排期页
2559
- async function fetchGame8Endfield(pageUrl) {
2716
+ async function fetchGame8Endfield(pageUrl, _signal, tz) {
2560
2717
  const html = await proxyFetchText(pageUrl, "https://game8.co/");
2561
- return parseGame8Events(html) || parseGame8EventsLoose(html);
2718
+ return parseGame8Events(html, tz) || parseGame8EventsLoose(html, tz);
2562
2719
  }
2563
2720
 
2564
2721
  // 从 fz.wiki 页面(Next.js App Router)内联 RSC flight payload 中抽取 contentJson 的 JSON 字符串。
@@ -2593,7 +2750,7 @@ export function createEngine(env) {
2593
2750
  // 只收"有明确起止"的活动(timeRanges 末段的 open+close 都非空),
2594
2751
  // 与旧行为一致——无 close 的是新手/每周/引导等常驻活动,不当作当期活动。
2595
2752
  // 时间格式 "2026/9/2 7:00:00";外显=排序第一条(结束最早的),悬停按同序逐行。
2596
- function parseFzWikiActivities(html, now) {
2753
+ function parseFzWikiActivities(html, now, tz) {
2597
2754
  const obj = extractFzContentJson(html);
2598
2755
  if (!obj) return null;
2599
2756
  const acts = [];
@@ -2627,7 +2784,14 @@ export function createEngine(env) {
2627
2784
  // 一条活动都没解析出来 → 页面结构变了(不是"当期没活动"):抛错让该侧记 down,
2628
2785
  // 面板会显示"活动失败",而不是伪装成"新活动未公布"(历史教训:源站改版长期静默失灵)。
2629
2786
  if (acts.length === 0) throw new Error("fz-wiki-no-activities");
2630
- const parseT = (s) => new Date(String(s).replace(/\//g, "-")).getTime();
2787
+ // 时间格式 "2026/9/2 7:00:00"(fz.wiki 的**服务器墙钟**,+08)。
2788
+ // 不能直接丢给 `new Date(str)`:那个格式不是 ISO,会被当**本机时区**解释 →
2789
+ // 海外用户拿到偏移的时刻。这里显式拆出字段,按**源站时区**(`tz`)构造。
2790
+ const parseT = (s) => {
2791
+ const m = String(s).match(/^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?/);
2792
+ if (m) return sourceInstant(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), tz);
2793
+ return new Date(String(s).replace(/\//g, "-")).getTime();
2794
+ };
2631
2795
  const t0 = now || nowMs();
2632
2796
  // 覆盖当前时刻的活动统一排序(③ 结束时间升序)供悬停;外显另按类别优先挑选
2633
2797
  const activeActs = sortEventItems(acts
@@ -2636,7 +2800,7 @@ export function createEngine(env) {
2636
2800
  // 解析到活动、但当期没有覆盖当前时刻的 → 返回 null(这一侧记 nomatch = "新活动未公布")
2637
2801
  if (activeActs.length === 0) return null;
2638
2802
  const primary = pickEventPrimary(activeActs) || activeActs[0]; // 叙事活动/挑战活动优先于签到类
2639
- const win = fmtWindow(primary.startTs, primary.endTs);
2803
+ const win = fmtWindow(primary.startTs, primary.endTs, tz);
2640
2804
  return {
2641
2805
  banner: primary.name,
2642
2806
  bannerDatesRaw: win,
@@ -2648,15 +2812,15 @@ export function createEngine(env) {
2648
2812
  // 终末地(FZ Wiki 经 host 代理,无 CORS):活动排期页。
2649
2813
  // 三态口径:有当期活动 → 数据;解析到活动但没有当期 → null(nomatch);
2650
2814
  // 页面结构变了/一条都解析不出 → parseFzWikiActivities 抛错(down)。
2651
- async function fetchFzWikiEndfield(pageUrl, now = nowMs()) {
2815
+ async function fetchFzWikiEndfield(pageUrl, _signal, tz, now = nowMs()) {
2652
2816
  const html = await proxyFetchText(pageUrl, "https://fz.wiki/");
2653
- return parseFzWikiActivities(html, now);
2817
+ return parseFzWikiActivities(html, now, tz);
2654
2818
  }
2655
2819
 
2656
2820
  // 通用活动源解析(自定义条目/自定义活动来源地址用):抓取页面 → parseGenericEvents → {event, eventDates}
2657
- async function tryParseGenericEvent(url, signal) {
2821
+ async function tryParseGenericEvent(url, signal, tz) {
2658
2822
  const html = await fetchHtmlText(url, signal);
2659
- const cur = parseGenericEvents(html);
2823
+ const cur = parseGenericEvents(html, tz);
2660
2824
  if (cur && cur.banner) return {
2661
2825
  event: cur.banner,
2662
2826
  eventDates: cur.bannerDates || "",
@@ -2669,19 +2833,20 @@ export function createEngine(env) {
2669
2833
  // 改用 api.php?action=ask 直接查询「分类:活动」的开始/结束时间,选当期。
2670
2834
  // 属性:名称/开始时间/结束时间/所属版本;结束时间 9999/01/01 为永久活动占位(跳过)。
2671
2835
  // 查询 URL 由 fetchYsActivity 构造,浏览器直连(api.php 带 origin=* 有 CORS)。
2672
- function parseSmwActivity(json) {
2836
+ function parseSmwActivity(json, tz) {
2673
2837
  const results = json?.query?.results || {};
2674
2838
  const now = nowMs();
2675
2839
  const covering = [];
2676
- // SMW timestamp 是 UTC 秒,raw 形如 "1/2026/8/28/10/0/0/0"(服务器本地时间 +08)。
2677
- // 用 raw 直接构造本地时间,避免 UTC 秒被本地时区再偏移。
2840
+ // SMW timestamp 是 UTC 秒,raw 形如 "1/2026/8/28/10/0/0/0"(**服务器本地时间 +08**)。
2841
+ // 用 raw 的值直接构造,但必须按**源站时区**(`tz`)解释 —— 否则海外用户
2842
+ // 得到的绝对时刻会整体偏移("是否在开/倒计时"随之出错)。
2678
2843
  const parseRaw = (v) => {
2679
2844
  if (!v) return null;
2680
2845
  if (v.raw != null) {
2681
2846
  const p = String(v.raw).split("/");
2682
2847
  if (p.length >= 8) {
2683
2848
  const y = Number(p[1]), mo = Number(p[2]), d = Number(p[3]), h = Number(p[4]), mi = Number(p[5]);
2684
- if (y && mo && d) return new Date(y, mo - 1, d, h || 0, mi || 0).getTime();
2849
+ if (y && mo && d) return sourceInstant(y, mo, d, h || 0, mi || 0, tz);
2685
2850
  }
2686
2851
  }
2687
2852
  // 回退:value 若是严格 ISO 本地时间则用之(避免把展示文本误判为时间)
@@ -2716,7 +2881,7 @@ export function createEngine(env) {
2716
2881
  // 悬停按结束时间升序;外显按类别优先(剧情活动/挑战类优先于常规/网页类)
2717
2882
  const ordered = sortEventItems(covering);
2718
2883
  const best = pickEventPrimary(ordered) || ordered[0];
2719
- const win = fmtWindow(best.startTs, best.endTs);
2884
+ const win = fmtWindow(best.startTs, best.endTs, tz);
2720
2885
  return {
2721
2886
  banner: best.name,
2722
2887
  roles: "",
@@ -2728,14 +2893,14 @@ export function createEngine(env) {
2728
2893
 
2729
2894
  // 原神 SMW 活动查询(经 origin=* 直连)
2730
2895
  // 返回 EVENT_FETCHERS 契约格式 {event, eventDates, eventDatesRaw}(parseSmwActivity 产出卡池格式,这里转换)
2731
- async function fetchYsActivity(signal) {
2896
+ async function fetchYsActivity(signal, tz) {
2732
2897
  const nowYear = new Date(nowMs()).getFullYear(); // 走注入时钟(core 不得直接读宿主时钟)
2733
2898
  const q = "[[\u5206\u7C7B:\u6D3B\u52A8]][[\u7ED3\u675F\u65F6\u95F4::>" + nowYear + "/01/01]]|?\u540D\u79F0|?\u5F00\u59CB\u65F6\u95F4|?\u7ED3\u675F\u65F6\u95F4|?\u7C7B\u578B|sort=\u5F00\u59CB\u65F6\u95F4|order=desc|limit=60";
2734
2899
  const apiUrl = "https://wiki.biligame.com/ys/api.php?action=ask&query=" + encodeURIComponent(q) + "&format=json&origin=*";
2735
2900
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
2736
2901
  if (!res.ok) throw new Error("http-" + res.status);
2737
2902
  const json = await res.json();
2738
- const d = parseSmwActivity(json);
2903
+ const d = parseSmwActivity(json, tz);
2739
2904
  if (!d) return null;
2740
2905
  return {
2741
2906
  event: d.banner,
@@ -2750,24 +2915,26 @@ export function createEngine(env) {
2750
2915
  // mkRaw:直接抓取原始 HTML(非 MediaWiki 源,如 GachaTracker)
2751
2916
  // 经 host 同源代理的来源(蔚蓝系列 / 终末地 wiki.gg / 1999 等)不用工厂包装:
2752
2917
  // 其抓取器内部自行调用 proxyFetchText / proxyFetchJson(绕过 CORS 与 Referer 反爬)。
2753
- // 抓取器签名:async (url, signal) → 数据对象 | null
2918
+ // 抓取器签名:async (url, signal, tz) → 数据对象 | null
2919
+ // 第三个参数 `tz` = 源站墙钟时区(可选,见 15-env.js)。工厂把它透传给解析函数,
2920
+ // 解析函数再交给 parseTime/parseRange —— 这样"源站时区"只需在来源声明里写一次。
2754
2921
  function mkMediaWiki(parse) {
2755
- return async (url, signal) => {
2922
+ return async (url, signal, tz) => {
2756
2923
  const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
2757
2924
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
2758
2925
  if (!res.ok) throw new Error("http-" + res.status);
2759
2926
  const json = await res.json();
2760
2927
  const text = json?.parse?.text;
2761
2928
  if (typeof text !== "string") throw new Error("bad-json");
2762
- return parse(text);
2929
+ return parse(text, tz);
2763
2930
  };
2764
2931
  }
2765
2932
  function mkRaw(parse) {
2766
- return async (url, signal) => {
2933
+ return async (url, signal, tz) => {
2767
2934
  const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
2768
2935
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
2769
2936
  if (!res.ok) throw new Error("http-" + res.status);
2770
- return parse(await res.text());
2937
+ return parse(await res.text(), tz);
2771
2938
  };
2772
2939
  }
2773
2940
  // bwiki 通用"选当期"包装(原神/星铁/绝区零/方舟)
@@ -2848,7 +3015,7 @@ export function createEngine(env) {
2848
3015
 
2849
3016
  // 蔚蓝档案·国际服:nexon 官方「更新日誌」board(3352) → 当期卡池 + 当期活动
2850
3017
  // 一次请求拿到更新日誌正文,从日程表同时提取 特選招募(卡池)与 活動劇情/總力戰(活动)
2851
- async function fetchBaGlobal(logListUrl, signal, now = nowMs()) {
3018
+ async function fetchBaGlobal(logListUrl, signal, tz, now = nowMs()) {
2852
3019
  const ref = "https://forum.nexon.com/bluearchiveTW/";
2853
3020
  const list = await proxyFetchJson(logListUrl, ref);
2854
3021
  const threads = Array.isArray(list?.threads) ? list.threads : [];
@@ -2983,7 +3150,7 @@ export function createEngine(env) {
2983
3150
  // ▼実施期間 2026年9月9日(水) メンテナンス後 ~ 2026年9月23日(水・祝) 10:59
2984
3151
  // 时间按 JST(+09:00) 解析(展示时转本地);起点写「メンテナンス後」时取同期维护公告
2985
3152
  // 「▼実施時間 … ~ … 17:00前後」的结束时刻。活动取同期「イベント」条目的開催期間。
2986
- function parseBaJpNews(json, now = nowMs()) {
3153
+ function parseBaJpNews(json, now = nowMs(), tz) {
2987
3154
  const rows = Array.isArray(json?.data?.rows) ? json.data.rows : [];
2988
3155
  const clean = (s) => String(s || "")
2989
3156
  .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
@@ -3029,7 +3196,7 @@ export function createEngine(env) {
3029
3196
  // 展示用的档期必须取"被选中的那个池"自己的窗口(cur[0])—— 旧实现固定取 windows[0],
3030
3197
  // 一旦当期命中的不是第一个池,就会显示"B 池名字 + A 池时间",倒计时按错档期跑。
3031
3198
  const win0 = cur[0];
3032
- const dates = win0.startTs != null && win0.endTs != null ? fmtWindow(win0.startTs, win0.endTs) : (win0.raw || "");
3199
+ const dates = win0.startTs != null && win0.endTs != null ? fmtWindow(win0.startTs, win0.endTs, tz) : (win0.raw || "");
3033
3200
  const data = {
3034
3201
  banner: cur[0].name,
3035
3202
  roles: [...new Set(cur.map((p) => p.student))].join("、"),
@@ -3069,10 +3236,10 @@ export function createEngine(env) {
3069
3236
 
3070
3237
  // 日服卡池默认抓取器:官网新闻接口优先;失败或无当期募集 → 回退 GameKee 当期卡池(仅池名+档期)。
3071
3238
  // 只返回卡池字段:活动字段由活动源单独负责(卡池/活动解耦,避免活动源失败时静默混入官方活动)
3072
- async function fetchBaJpGacha(url, signal, now = nowMs()) {
3239
+ async function fetchBaJpGacha(url, signal, tz, now = nowMs()) {
3073
3240
  try {
3074
3241
  const json = await proxyFetchJson(url, "https://bluearchive.jp/");
3075
- const d = parseBaJpNews(json, now);
3242
+ const d = parseBaJpNews(json, now, tz);
3076
3243
  if (d) {
3077
3244
  delete d.event;
3078
3245
  delete d.eventDates;
@@ -3084,9 +3251,9 @@ export function createEngine(env) {
3084
3251
  }
3085
3252
 
3086
3253
  // 日服活动备选抓取器:同一个官方接口的「イベント」条目(只取 event 字段)
3087
- async function fetchBaJpOfficialEvent(url, signal, now = nowMs()) {
3254
+ async function fetchBaJpOfficialEvent(url, signal, tz, now = nowMs()) {
3088
3255
  const json = await proxyFetchJson(url, "https://bluearchive.jp/");
3089
- const d = parseBaJpNews(json, now);
3256
+ const d = parseBaJpNews(json, now, tz);
3090
3257
  if (!d || !d.event) return null;
3091
3258
  return { event: d.event, eventDates: d.eventDates || "", eventDatesRaw: d.eventDatesRaw || d.eventDates || "" };
3092
3259
  }
@@ -3187,7 +3354,7 @@ export function createEngine(env) {
3187
3354
 
3188
3355
  // 蔚蓝国服(官网 bluearchive-cn.com):news/list → 最新维护更新说明 → 当期卡池/活动名 + 维护起止
3189
3356
  // 时间:维护日 14:00 ~ 下次维护前(约 +14 天,取维护日开始、预加载下一期预告前)
3190
- async function fetchBaCn(listUrl, now = nowMs()) {
3357
+ async function fetchBaCn(listUrl, _signal, tz, now = nowMs()) {
3191
3358
  const H = { "game-alias": "ba" };
3192
3359
  const ref = "https://bluearchive-cn.com/";
3193
3360
  const nowYear = new Date(now).getFullYear();
@@ -3233,7 +3400,7 @@ export function createEngine(env) {
3233
3400
  .replace(/&ldquo;/g, "「").replace(/&rdquo;/g, "」")
3234
3401
  .replace(/&hellip;/g, "…").replace(/&times;/g, "×").replace(/&bull;/g, "·")
3235
3402
  .replace(/\n\s*\n+/g, "\n").trim();
3236
- return parseBaCnMaintText(text, nowYear);
3403
+ return parseBaCnMaintText(text, nowYear, tz);
3237
3404
  }
3238
3405
  // 国服公告里「招募」开头的东西**不是一类**。实测(09月24日维护更新说明):
3239
3406
  // 更新限时限定招募【秩序隐匿于粼粼波光中】 ← 限定角色卡池(当期主卡池)
@@ -3261,7 +3428,7 @@ export function createEngine(env) {
3261
3428
  // 抽出来是为了能用合成文本精确回归 —— 这个源的坑全在措辞(见上面类型表),
3262
3429
  // 而措辞能不能认出来只能靠测解析,靠抓线上只能"等它坏了才发现"。
3263
3430
  // 无角色卡池时返回 null(调用方据此判"未公布")。
3264
- function parseBaCnMaintText(text, nowYear) {
3431
+ function parseBaCnMaintText(text, nowYear, tz) {
3265
3432
  // 卡池识别分两步:① 挑出**角色卡池**(排除招募活动);② 按档位选外显那一类。
3266
3433
  //
3267
3434
  // WHY 不能只认一种写法:措辞漂移过。旧写法 `更新限时招募【X】` 曾一度**一处都没有**
@@ -3609,20 +3776,20 @@ export function createEngine(env) {
3609
3776
 
3610
3777
  // 重返未来:1999 入口:默认源=官方游戏内公告(逐期征集时间);
3611
3778
  // 来源被切到官网公告(备选源)或自定义地址时直接走官网解析(旧行为)。
3612
- async function fetchR99(url, signal, now = nowMs()) {
3779
+ async function fetchR99(url, signal, tz, now = nowMs()) {
3613
3780
  if (!/noticecp/.test(String(url || ""))) return fetchR99Official(url, signal, now);
3614
3781
  try {
3615
3782
  return await fetchR99Notice(now);
3616
3783
  } catch {
3617
3784
  // 游戏内公告接口不可用/结构变了 → 回退官网公告(宁可少时间信息,也不要整格报错)
3618
- return fetchR99Official(R1999_OFFICIAL_URL, signal, now);
3785
+ return fetchR99Official(R1999_OFFICIAL_URL, signal, tz, now);
3619
3786
  }
3620
3787
  }
3621
3788
 
3622
3789
  // 重返未来:1999(官网 re.bluepoch.com 新闻 API,POST 经 host 代理)
3623
3790
  // 列表接口(informationType=2 资讯)按上线时间倒序返回含全文的公告,
3624
3791
  // 取最新一期「版本更新维护公告」:当期卡池(首位6星角色名,官网无征集名)/ 当期活动 / 维护起止 + 下一期维护日
3625
- async function fetchR99Official(listUrl, signal, now = nowMs()) {
3792
+ async function fetchR99Official(listUrl, signal, tz, now = nowMs()) {
3626
3793
  const ref = "https://re.bluepoch.com/";
3627
3794
  const list = await proxyFetchJson(listUrl, ref, {}, { current: 1, pageSize: 30, informationType: 2 });
3628
3795
  const items = list?.data?.pageData || [];
@@ -3742,34 +3909,38 @@ export function createEngine(env) {
3742
3909
  const GACHA_FETCHERS = {
3743
3910
  genshin: mkMediaWiki(bwikiGachaPayload),
3744
3911
  hsr: mkMediaWiki(bwikiGachaPayload),
3745
- zzz: (url, signal) => fetchZzzGacha(url, signal),
3912
+ zzz: (url, signal, tz) => fetchZzzGacha(url, signal, tz),
3746
3913
  "zzz-bwiki": mkMediaWiki(pickCurrent(parseAllBwiki)),
3747
- arknights: (url, signal) => fetchArknightsGacha(url, signal),
3748
- "arknights-prts": mkMediaWiki(selectArknights),
3749
- wuwa: (url, signal) => fetchWuwaGacha(url, signal),
3750
- "wuwa-bwiki": mkMediaWiki(parseWuwaPool),
3914
+ arknights: (url, signal, tz) => fetchArknightsGacha(url, signal, tz),
3915
+ // ⚠️ `selectArknights` / `parseWuwaPool` 的**第二参是 `now`**(不是 tz),
3916
+ // 而 mkMediaWiki 只会传 `(text, tz)` —— 直接包进去会让 `now` 收到时区值,
3917
+ // `startTs <= now` 恒为 false → 解析结果恒为 null(静默"未公布",极难查)。
3918
+ // 所以这两个注册点必须**显式传 nowMs()**。
3919
+ "arknights-prts": (url, signal, tz) => mkMediaWiki((text) => selectArknights(text, nowMs(), tz))(url, signal, tz),
3920
+ wuwa: (url, signal, tz) => fetchWuwaGacha(url, signal, tz),
3921
+ "wuwa-bwiki": (url, signal, tz) => mkMediaWiki((text) => parseWuwaPool(text, nowMs(), tz))(url, signal, tz),
3751
3922
  // 终末地默认:Canmoe(中文,Next.js 数据经 host 代理两步抓取)
3752
- endfield: (url, signal) => fetchCanmoeEndfield(url),
3923
+ endfield: (url, signal, tz) => fetchCanmoeEndfield(url, signal, tz),
3753
3924
  // 终末地备选:GachaTracker(英文,浏览器直连)/ wiki.gg(英文,经 host 代理)
3754
3925
  "endfield-gachatracker": mkRaw(parseGachaTracker),
3755
- "endfield-wiki-gg": (url, signal) => fetchEndfieldWikiGg(url),
3926
+ "endfield-wiki-gg": (url, signal, tz) => fetchEndfieldWikiGg(url, signal, tz),
3756
3927
  // 异环:ldshop(繁体,静态表格经 host 代理)
3757
- nte: (url, signal) => fetchNteWanmei(url, signal),
3758
- "nte-ldshop": (url, signal) => fetchLdshopNte(url),
3759
- "ba-cn": (url, signal) => fetchBaCn(url),
3760
- "ba-global": (url, signal) => fetchBaGlobal(url, signal),
3928
+ nte: (url, signal, tz) => fetchNteWanmei(url, signal, tz),
3929
+ "nte-ldshop": (url, signal, tz) => fetchLdshopNte(url, signal, tz),
3930
+ "ba-cn": (url, signal, tz) => fetchBaCn(url, signal, tz),
3931
+ "ba-global": (url, signal, tz) => fetchBaGlobal(url, signal, tz),
3761
3932
  "ba-global-gamekee": () => fetchGameKeeBa("global"),
3762
- "ba-jp": (url, signal) => fetchBaJpGacha(url, signal),
3933
+ "ba-jp": (url, signal, tz) => fetchBaJpGacha(url, signal, tz),
3763
3934
  "ba-jp-gamekee": () => fetchGameKeeBa("jp"),
3764
- "r1999": (url, signal) => fetchR99(url, signal),
3935
+ "r1999": (url, signal, tz) => fetchR99(url, signal, tz),
3765
3936
  // 重返未来1999 备选:官网公告(只有维护时间与活动名,无逐期征集时间)
3766
- "r1999-official": (url, signal) => fetchR99Official(url, signal)
3937
+ "r1999-official": (url, signal, tz) => fetchR99Official(url, signal, tz)
3767
3938
  };
3768
3939
  // 活动源注册表:条目 → { 默认 + 备选抓取器 }。没有独立活动源的条目活动来源显示"未配置"。
3769
3940
  const EVENT_FETCHERS = {
3770
3941
  // 原神:活动一览为 JS 动态加载(Dquery+SMW),走 SMW ask 查询(fetchYsActivity 忽略 URL 参数)
3771
3942
  genshin: {
3772
- default: (url, signal) => fetchYsActivity(signal)
3943
+ default: (url, signal, tz) => fetchYsActivity(signal, tz)
3773
3944
  },
3774
3945
  // 星铁:活动一览(静态「活动时间」表,api.php 可直连)→ 外显当期 + 悬停列出全部并行活动
3775
3946
  hsr: {
@@ -3778,14 +3949,14 @@ export function createEngine(env) {
3778
3949
  // 绝区零:默认=官方公告(api-takumi-static,与卡池侧同一接口;活动时间写在公告正文里,
3779
3950
  // 含"X.Y版本更新后/版本结束"的换算;结束时间未知的活动保留并沉底)→ 备选=Bwiki 活动一览
3780
3951
  zzz: {
3781
- default: (url, signal) => fetchZzzEventsOfficial(url, signal),
3952
+ default: (url, signal, tz) => fetchZzzEventsOfficial(url, signal, tz),
3782
3953
  "zzz-event-bwiki": mkMediaWiki(genericEventPayload)
3783
3954
  },
3784
- // 异环:活动源就是同一篇官网公告(与卡池侧同址,fetchNteWanmei 一个函数同时解析两者)。
3955
+ // 异环:活动源就是同一篇官网公告(与卡池侧**同一条 URL**,fetchNteWanmei 一个函数同时解析两者)。
3785
3956
  // 注册成独立活动源的意义:卡池侧本轮抓挂、或用户把**卡池**来源改成自定义/备选时,
3786
- // 活动侧仍能自己抓、自己报错,而不是整列空掉(同址复用只是"能省一次请求"的优化,不是它的腿)。
3957
+ // 活动侧仍能自己抓、自己报错,而不是整列空掉(复用只是"同一 URL 省一次请求"的优化,不是它的腿)。
3787
3958
  nte: {
3788
- default: (url, signal) => fetchNteWanmei(url, signal)
3959
+ default: (url, signal, tz) => fetchNteWanmei(url, signal, tz)
3789
3960
  },
3790
3961
  // 明日方舟:PRTS 活动一览(「活动开始时间」表 + data-time 起止时间戳)→ 同上
3791
3962
  arknights: {
@@ -3793,44 +3964,44 @@ export function createEngine(env) {
3793
3964
  },
3794
3965
  // 终末地:FZ Wiki(中文,经 host 代理、抓 RSC 数据;外显当期=结束最晚,悬停列出全部并行)
3795
3966
  endfield: {
3796
- default: (url, signal) => fetchFzWikiEndfield(url).then((d) =>
3967
+ default: (url, signal, tz) => fetchFzWikiEndfield(url, signal, tz).then((d) =>
3797
3968
  d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null
3798
3969
  ),
3799
- "endfield-game8": (url, signal) => fetchGame8Endfield(url).then((d) =>
3970
+ "endfield-game8": (url, signal, tz) => fetchGame8Endfield(url, signal, tz).then((d) =>
3800
3971
  d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "" } : null
3801
3972
  )
3802
3973
  },
3803
- // 鸣潮:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动),与卡池同址
3804
- // → 一次请求复用两侧数据(refresh 的同址复用优化)。
3974
+ // 鸣潮:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动),与卡池是**同一条 URL**
3975
+ // → 一次请求复用两侧数据(refresh 的复用优化)。
3805
3976
  // ⚠️ 2026-10-01 改:Bwiki 活动日历**已停更**(最新一条结束于 2026/9/29),
3806
3977
  // 所以把官方提为默认、Bwiki 降级为备选(`wuwa-event-bwiki`),仍可在设置里手动切回。
3807
3978
  wuwa: {
3808
- default: (url, signal) => fetchWuwaEventsOfficial(url, signal),
3979
+ default: (url, signal, tz) => fetchWuwaEventsOfficial(url, signal, tz),
3809
3980
  "wuwa-event-bwiki": mkMediaWiki((html) => {
3810
- const d = parseWuwaCalendar(html);
3981
+ const d = parseWuwaCalendar(html, tz);
3811
3982
  return d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null;
3812
3983
  })
3813
3984
  },
3814
3985
  // 蔚蓝国服:默认与卡池同 URL(维护公告含活动名),也可独立配置其他来源
3815
3986
  "ba-cn": {
3816
- default: (url, signal) => fetchBaCn(url)
3987
+ default: (url, signal, tz) => fetchBaCn(url, signal, tz)
3817
3988
  },
3818
3989
  // 蔚蓝国际服:默认与卡池同 URL(更新日誌含活动排期);备选 GameKee
3819
3990
  "ba-global": {
3820
- default: (url, signal) => fetchBaGlobal(url, signal),
3991
+ default: (url, signal, tz) => fetchBaGlobal(url, signal, tz),
3821
3992
  "ba-global-gamekee": () => fetchGameKeeBa("global")
3822
3993
  },
3823
3994
  // 蔚蓝日服:活动源与卡池源独立——默认 GameKee 当期活动条目(原行为不变);
3824
3995
  // 备选 = 日服官方公告里的イベント条目(抓取器复用官方解析,仅取 event/eventDates)
3825
3996
  "ba-jp": {
3826
3997
  default: () => fetchGameKeeBa("jp"),
3827
- "ba-jp-official": (url, signal) => fetchBaJpOfficialEvent(url, signal)
3998
+ "ba-jp-official": (url, signal, tz) => fetchBaJpOfficialEvent(url, signal, tz)
3828
3999
  },
3829
4000
  // 重返未来:默认与卡池同 URL(同一篇「版本活动一览」同时含征集与活动);
3830
4001
  // 备选=官网公告(只有维护时间与活动名)
3831
4002
  "r1999": {
3832
- default: (url, signal) => fetchR99(url, signal),
3833
- "r1999-official": (url, signal) => fetchR99Official(url, signal)
4003
+ default: (url, signal, tz) => fetchR99(url, signal, tz),
4004
+ "r1999-official": (url, signal, tz) => fetchR99Official(url, signal, tz)
3834
4005
  }
3835
4006
  };
3836
4007
 
@@ -3869,16 +4040,17 @@ export function createEngine(env) {
3869
4040
  // 才允许通用解析兜底——内置默认源不兜底:代理源直连必被 CORS 拦,那个错误不该算到来源头上。
3870
4041
  // 返回 { data, fail }:fail=null 表示该侧成功,否则 { kind: "down"|"nomatch", reason }。
3871
4042
  // data 一律原样带回(未命中时也可能附带可供同 URL 复用的其它字段)。
3872
- async function resolveSide(side, { fetcher, url, allowGeneric, signal }) {
4043
+ // `tz`(可选)= 源站墙钟时区,透传给抓取器/解析器(不传 = 本机时区,向后兼容)。
4044
+ async function resolveSide(side, { fetcher, url, allowGeneric, signal, tz }) {
3873
4045
  const emptyOf = (d) => (side === "gacha" ? !d || !d.banner : !d || !d.event);
3874
4046
  const parseGeneric = () => (side === "gacha"
3875
- ? tryParseGenericGacha(url, signal)
3876
- : tryParseGenericEvent(url, signal));
4047
+ ? tryParseGenericGacha(url, signal, tz)
4048
+ : tryParseGenericEvent(url, signal, tz));
3877
4049
  let data = null;
3878
4050
  let fail = null;
3879
4051
  if (fetcher) {
3880
4052
  try {
3881
- data = await fetcher(url, signal);
4053
+ data = await fetcher(url, signal, tz);
3882
4054
  } catch (err) {
3883
4055
  if (err && err.name === "AbortError") throw err;
3884
4056
  fail = { kind: "down", reason: normErr(err) };
@@ -3916,7 +4088,8 @@ export function createEngine(env) {
3916
4088
  fetcher: gachaFetcherFor(source, gachaUrl),
3917
4089
  url: gachaUrl,
3918
4090
  allowGeneric: !!(source.custom || source.allowGenericGacha),
3919
- signal
4091
+ signal,
4092
+ tz: source.tz
3920
4093
  });
3921
4094
  }
3922
4095
  let evData = null;
@@ -3925,18 +4098,21 @@ export function createEngine(env) {
3925
4098
  // 活动侧自己的抓取器(与卡池侧各自独立选择,来源可以完全不同)。
3926
4099
  // 注意:**默认路径就是"活动侧自己抓"** —— 解耦是常态,复用只是优化。
3927
4100
  const evFetcher = eventFetcherFor(source, eventUrl);
3928
- // ── 同址复用:**命中才触发**的分支 ──────────────────────────────
4101
+ // ── 两侧同一条 URL 时的请求复用:**命中才触发**的分支 ───────────
3929
4102
  // 只有两个条件**同时**成立才复用,否则一律走下面的独立抓取:
3930
- // ① 两侧地址相同(同一次请求本来就能拿到两侧数据)
4103
+ // ① 两侧是**同一条 URL**(按**完整 URL 含查询参数**比,不是「同一个网站」)
4104
+ // —— 例如星铁的卡池与活动都是 wiki.biligame.com/sr/api.php,
4105
+ // 但 `page=历史跃迁` vs `page=活动一览`,**不是**同一条 URL;
4106
+ // 而绝区零/鸣潮两侧是字面完全相同的 URL,一次请求的响应完全一样。
3931
4107
  // ② 卡池那次的载荷**确实带了活动字段**(`event`)
3932
- // 复用只是"省掉一次本来会重复的请求",**不是解耦的腿**:
4108
+ // 复用只是"省掉一次**同一 URL** 的重复请求",**不是解耦的腿**:
3933
4109
  // 活动侧的抓取器、失败归因、来源选择都保持独立(见 §53)。
3934
4110
  //
3935
4111
  // 2026-10-01 实测(11 内置条目逐条):复用支命中 5 次
3936
4112
  // (wuwa / nte / r1999 / ba-cn / ba-global —— 它们的卡池载荷本身就含活动字段),
3937
4113
  // 其余 6 条目(zzz / genshin / hsr / arknights / ba-jp / endfield)走独立抓取。
3938
- const reuseSameUrl = eventUrl === gachaUrl && !!(g.data && g.data.event);
3939
- if (reuseSameUrl) {
4114
+ const reuseSameRequest = eventUrl === gachaUrl && !!(g.data && g.data.event);
4115
+ if (reuseSameRequest) {
3940
4116
  // 同 URL:活动字段就在卡池载荷里,不重复抓
3941
4117
  evData = {
3942
4118
  event: g.data.event,
@@ -3945,7 +4121,7 @@ export function createEngine(env) {
3945
4121
  eventHover: g.data.eventHover || ""
3946
4122
  };
3947
4123
  } else if (eventUrl === gachaUrl && !evFetcher) {
3948
- // ── 同址但活动侧**没有自己的抓取器**(该条目就只有这一份载荷可用)──
4124
+ // ── 两侧同一条 URL、但活动侧**没有自己的抓取器**(该条目就只有这一份载荷可用)──
3949
4125
  // ⚠️ **这一支不是死代码,别删**:**自定义条目**可以没有注册抓取器 ——
3950
4126
  // `getAllEntries` 里 customEntries 带 `custom:true` + 用户填的 url/eventUrl,
3951
4127
  // 而 GACHA_FETCHERS / EVENT_FETCHERS 里没有它们的 id → `eventFetcherFor` 返回 null。
@@ -3958,12 +4134,13 @@ export function createEngine(env) {
3958
4134
  // 把"当前没命中"当成了"不可达"。`_batch1` A3 用合成条目(sim-nte)刻意覆盖这两条语义。
3959
4135
  eventFail = g.data ? { kind: "nomatch" } : (g.fail || { kind: "nomatch" });
3960
4136
  } else {
3961
- // 活动侧独立抓取(含"地址不同"与"同址但载荷无活动字段"两种情形)
4137
+ // 活动侧独立抓取(含"两侧不是同一条 URL"与"同一条 URL 但载荷无活动字段"两种情形)
3962
4138
  const ev = await resolveSide("event", {
3963
4139
  fetcher: evFetcher,
3964
4140
  url: eventUrl,
3965
4141
  allowGeneric: !!(source.custom || source.allowGenericEvent),
3966
- signal
4142
+ signal,
4143
+ tz: source.tz
3967
4144
  });
3968
4145
  evData = ev.data;
3969
4146
  eventFail = ev.fail;
@@ -4111,14 +4288,48 @@ export function createEngine(env) {
4111
4288
  if (refreshInFlight) return refreshInFlight;
4112
4289
  refreshInFlight = (async () => {
4113
4290
  const s = await readSettings();
4114
- // 用"全部条目"(含隐藏)抓取:隐藏再显示时立刻有数据,与既有行为一致
4115
- const entries = getAllEntries(s);
4291
+ // 只抓"选中展示"的条目(getVisibleEntries = 全部 - hidden):
4292
+ // 面板本就只渲染可见条目(80-components 用同一个函数),抓隐藏条目是**纯浪费**——
4293
+ // 每轮都为它们发请求、解析、写缓存(自定义条目 404 还会一直计入失败统计)。
4294
+ // 隐藏条目的**旧数据原样保留**,所以取消隐藏时仍能立刻看到上次内容(见下面 forEach 的 else 支)。
4295
+ //
4296
+ // ⚠️ 索引必须**按 id 映射**,不能用 `entries.indexOf(e)`:
4297
+ // `getVisibleEntries(s)` 内部自己又调了一次 `getAllEntries(s)`,返回的是**另一批对象**,
4298
+ // 与这里的 `allEntries` 元素不是同一个引用 → `indexOf` 恒为 -1 →
4299
+ // `result.results[-1]` 是 undefined → 刷新直接抛 TypeError。
4300
+ // (2026-10-01 实测踩到:**没有隐藏条目的用户一刷新就崩**,正是最普遍的那种配置。)
4301
+ const allEntries = getAllEntries(s);
4302
+ const entries = getVisibleEntries(s);
4303
+ const resultByIndex = new Map(entries.map((e, i) => [e.id, i]));
4116
4304
  const result = await refreshAll(entries, s);
4117
4305
  const prev = await getCached();
4118
4306
  const games = {};
4119
- entries.forEach((e, i) => {
4120
- const rec = mergeEntryRecord(result.results[i], prev.games[e.id], nowMs());
4121
- if (result.results[i].reason === "skipped") rec.skipped = true;
4307
+ allEntries.forEach((e) => {
4308
+ const i = resultByIndex.get(e.id);
4309
+ if (i === void 0) {
4310
+ // 隐藏(未选中展示)→ 本轮**不抓、也不判定**,但要把两件事分开处理:
4311
+ //
4312
+ // · **内容字段与 okAt 原样保留** —— 取消隐藏时仍能立刻看到上次内容(既有体验)。
4313
+ // · **失败状态(gachaFail/eventFail)与 stale 标记一律清空** ——
4314
+ // 隐藏期间我们**没有发起请求**,所以"失败"这个判断**根本不成立**:
4315
+ // 保留一个未经验证的旧失败等于在说谎。更要命的是它会被**冻结**:
4316
+ // 这里只做 `normalizeRecord(prev.games[e.id])` 的搬运、从不重新判定,
4317
+ // 于是那条失败原因会一直是"隐藏前最后一次抓取"留下的值——
4318
+ // 实测:源站恢复后连刷 3 轮,隐藏记录里的 `抓取异常` 依然不动。
4319
+ // 后果:用户取消隐藏后,会先看到一条**可能早已过期**的失败原因。
4320
+ // (2026-10-01 用户追问"失败原因是哪来的"后定为方案 B。)
4321
+ const kept = prev.games[e.id];
4322
+ const blank = { ...(kept || {}) };
4323
+ delete blank.gachaFail;
4324
+ delete blank.eventFail;
4325
+ delete blank.gachaStale;
4326
+ delete blank.eventStale;
4327
+ games[e.id] = normalizeRecord(blank, e.name);
4328
+ return;
4329
+ }
4330
+ const r = result.results[i] || { reason: "skipped" };
4331
+ const rec = mergeEntryRecord(r, prev.games[e.id], nowMs());
4332
+ if (r.reason === "skipped") rec.skipped = true;
4122
4333
  // 统一成固定形状(内容字段缺失填空串)——见 engine-head.js 的 normalizeRecord
4123
4334
  games[e.id] = normalizeRecord(rec, e.name);
4124
4335
  });
@@ -4143,11 +4354,15 @@ export function createEngine(env) {
4143
4354
  // 解析器自检(交接文档 §13.4):对每个条目的两侧来源各跑一次,报告「解析出什么 / 报错原因」。
4144
4355
  // 用途:源站改版时快速定位「哪个源解析出 0 条、哪个源 403/超时」——各平台都能调用
4145
4356
  // (将来扩展里做「自检」按钮、CLI、CI 都行)。**只读**:不写缓存、不动设置。
4357
+ //
4358
+ // 口径与刷新一致:**只自检"选中展示"的条目**(同 getVisibleEntries)。
4359
+ // 理由:自检的用途是"定位当前实际在用的源",而隐藏条目既不在面板显示、也不参与刷新;
4360
+ // 把它们一起报出来只会让结论里混进与当前无关的失败(如已隐藏的自定义条目 404)。
4146
4361
  async function selfCheck(options) {
4147
4362
  useEnv();
4148
4363
  const timeoutMs = (options && options.timeoutMs) || REFRESH_TIMEOUT_MS;
4149
4364
  const s = await readSettings();
4150
- const entries = getAllEntries(s);
4365
+ const entries = getVisibleEntries(s);
4151
4366
  const targets = entries.map((e) => ({
4152
4367
  ...e,
4153
4368
  url: getEntryUrl(s, e.id, e.url),