gacha-calendar-core 0.10.7 → 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 +519 -187
  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.7";
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";
@@ -61,6 +73,10 @@
61
73
  // 其中 recommend 组含逐期「角色/武器活动唤取」公告,正文带 ✦活动时间✦ 起止
62
74
  const WUWA_NOTICE_ENTRY = "https://aki-gm-resources-back.aki-game.com/gamenotice/G152/76402e5b20be2c39f095a152090afddc/entrypoint.json";
63
75
  const WUWA_BWIKI_URL = "https://wiki.biligame.com/wutheringwaves/api.php?action=parse&page=%E9%A6%96%E9%A1%B5%2F%E8%A7%92%E8%89%B2%E8%BD%AE%E6%8D%A2%E6%B1%A0&prop=text&format=json&formatversion=2";
76
+ // 鸣潮 Bwiki 活动日历页(已从**默认活动源降级为备选**,2026-10-01):
77
+ // 该页仍可解析(parseWuwaCalendar 能抽到 11 条),但**已停更**——最新一条结束于 2026/9/29,
78
+ // 没有当期 3.7 的活动,所以不改解析器,只把它挪到备选。
79
+ const WUWA_EVENT_BWIKI_URL = "https://wiki.biligame.com/wutheringwaves/api.php?action=parse&page=%E9%A6%96%E9%A1%B5%2F%E6%B4%BB%E5%8A%A8%E6%97%A5%E5%8E%86&prop=text&format=json&formatversion=2";
64
80
  // 绝区零官网公告(api-takumi-static content_v2_user,经 host 代理;无 CORS、无需登录):
65
81
  // iChanId=279(公告频道)返回逐条公告正文(sIntro/sContent),其中「X.Y版本限时频段(上/下期)」
66
82
  // 含该期精确起止 + 限定 S 级代理人/音擎;起点写「版本更新后」时取同版本「更新公告」的 dtStartTime。
@@ -78,6 +94,7 @@
78
94
  const SOURCES = [
79
95
  {
80
96
  id: "genshin",
97
+ tz: TZ_CN,
81
98
  // parserVersion:该条目「解析逻辑」的版本号 —— 源站改版/规则更新后 +1。
82
99
  // 用途:无服务端分发时定位「坏了的是哪个版本的用户、哪个源」(见交接文档 §13.4)。
83
100
  parserVersion: 1,
@@ -91,6 +108,7 @@
91
108
  },
92
109
  {
93
110
  id: "hsr",
111
+ tz: TZ_CN,
94
112
  parserVersion: 1,
95
113
  name: "崩坏:星穹铁道",
96
114
  icon: "https://storage.moegirl.org.cn/moegirl/commons/3/38/HonkaiStarRailIcon_StartingVer3.6_CHN.png!/fw/64",
@@ -102,6 +120,7 @@
102
120
  },
103
121
  {
104
122
  id: "zzz",
123
+ tz: TZ_CN,
105
124
  parserVersion: 2,
106
125
  name: "绝区零",
107
126
  icon: "https://storage.moegirl.org.cn/moegirl/commons/3/3e/ZZZ_miYoYo_logo.jpg!/fw/64",
@@ -128,22 +147,32 @@
128
147
  },
129
148
  {
130
149
  id: "wuwa",
131
- parserVersion: 1,
150
+ tz: TZ_CN,
151
+ parserVersion: 2,
132
152
  name: "鸣潮",
133
153
  icon: "https://storage.moegirl.org.cn/moegirl/commons/2/29/WutheringWavesIcon.png!/fw/64",
134
154
  source: "官方公告",
135
- // 默认卡池源=官方公告(aki-gm-resources-back,经 host 代理):逐期「角色/武器活动唤取」公告含起止时间;
155
+ // 默认卡池源=官方公告(aki-gm-resources-back,经 host 代理):
156
+ // entrypoint.json → <dir>/zh-Hans.json,其 `recommend` 组里 tag=7 为逐期「角色/武器活动唤取」,
157
+ // 条目自带 `startTimeMs`/`endTimeMs` 绝对时间戳(不再解析正文,见 parseWuwaNotice)。
136
158
  // 无当期公告或抓取失败时由抓取器自动回退 Bwiki 角色轮换池;也可在设置中手动切 Bwiki(备选)
137
159
  url: WUWA_NOTICE_ENTRY,
138
160
  altSources: [
139
161
  { label: "Bwiki 角色轮换池", url: WUWA_BWIKI_URL, fetcher: "wuwa-bwiki" }
140
162
  ],
141
- // 独立活动源:鸣潮活动日历页(与卡池源不同)
142
- eventUrl: "https://wiki.biligame.com/wutheringwaves/api.php?action=parse&page=%E9%A6%96%E9%A1%B5%2F%E6%B4%BB%E5%8A%A8%E6%97%A5%E5%8E%86&prop=text&format=json&formatversion=2",
143
- eventSource: "Bwiki 活动日历"
163
+ // 活动源:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动,见 parseWuwaRecommendEvents),
164
+ // 与卡池是**同一条 URL** → 一次请求复用两侧数据。
165
+ // ⚠️ 2026-10-01 改:Bwiki 活动日历页**已停更**(最新一条结束于 2026/9/29,无 3.7 内容),
166
+ // 所以把它从默认**降级为备选**(eventAltSources),仍可在设置里手动切回。
167
+ eventUrl: WUWA_NOTICE_ENTRY,
168
+ eventSource: "官方公告",
169
+ eventAltSources: [
170
+ { label: "Bwiki 活动日历", url: WUWA_EVENT_BWIKI_URL, fetcher: "wuwa-event-bwiki" }
171
+ ]
144
172
  },
145
173
  {
146
174
  id: "arknights",
175
+ tz: TZ_CN,
147
176
  // 2:档位改按池名判定(中坚优先)+ 外显与悬停共用同一份排序列表(v0.9.25 修)
148
177
  parserVersion: 2,
149
178
  name: "明日方舟",
@@ -161,6 +190,7 @@
161
190
  },
162
191
  {
163
192
  id: "endfield",
193
+ tz: TZ_CN,
164
194
  parserVersion: 2,
165
195
  name: "明日方舟:终末地",
166
196
  icon: "https://storage.moegirl.org.cn/moegirl/commons/f/f1/ArknightsEndfieldAppIcon.png!/fw/64",
@@ -192,6 +222,7 @@
192
222
  },
193
223
  {
194
224
  id: "ba-cn",
225
+ tz: TZ_CN,
195
226
  parserVersion: 1,
196
227
  name: "蔚蓝档案·国服",
197
228
  icon: "https://webcnstatic.yostar.net/ba_cn_web/prod/web/favicon.png?x-oss-process=image/resize,w_64",
@@ -204,6 +235,7 @@
204
235
  },
205
236
  {
206
237
  id: "ba-global",
238
+ tz: TZ_UTC,
207
239
  parserVersion: 1,
208
240
  name: "蔚蓝档案·国际服",
209
241
  icon: "https://storage.moegirl.org.cn/moegirl/commons/2/25/AppIcon_Arona.png!/fw/64",
@@ -224,6 +256,7 @@
224
256
  },
225
257
  {
226
258
  id: "ba-jp",
259
+ tz: TZ_JP,
227
260
  parserVersion: 1,
228
261
  name: "蔚蓝档案·日服",
229
262
  icon: "https://play-lh.googleusercontent.com/H975s6W1-boCSogzpF5_rIyawbjiXfG842ncgjIRiVGzhXHFTCVut0DkBhlDR4CgN1nn98OOC1fWN-LE7kUHnQ=s64",
@@ -245,6 +278,7 @@
245
278
  },
246
279
  {
247
280
  id: "r1999",
281
+ tz: TZ_CN,
248
282
  parserVersion: 3,
249
283
  name: "重返未来:1999",
250
284
  icon: "https://play-lh.googleusercontent.com/LwcueZMBbLq6aELtqJVn61ToKkJUgxEO8O4KgK_5052hfYoDAglQJIzqSu8srUJeaOZwv36Qi5YKtsXZjo-JPg=s64",
@@ -258,7 +292,7 @@
258
292
  altSources: [
259
293
  { label: "\u5B98\u7F51\u516C\u544A+\u5C0F\u7C73", url: R1999_OFFICIAL_URL, fetcher: "r1999-official" }
260
294
  ],
261
- // 活动源与卡池源同址(同一篇「版本活动一览」同时含征集与活动),仍作为独立来源存在
295
+ // 活动源与卡池源是**同一条 URL**(同一篇「版本活动一览」同时含征集与活动),仍作为独立来源存在
262
296
  eventUrl: R1999_NOTICE_URL,
263
297
  eventSource: "\u5B98\u65B9\u516C\u544A",
264
298
  eventAltSources: [
@@ -267,6 +301,7 @@
267
301
  },
268
302
  {
269
303
  id: "nte",
304
+ tz: TZ_CN,
270
305
  parserVersion: 2,
271
306
  name: "异环",
272
307
  icon: "https://storage.moegirl.org.cn/moegirl/commons/8/8c/YH_APP.png!/fw/64",
@@ -810,6 +845,104 @@ export function createEngine(env) {
810
845
  return h;
811
846
  }
812
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
+
813
946
  // 经代理抓取文本(需要绕过 CORS / Referer 反爬的源)。语义与原 host 代理调用完全一致:
814
947
  // referer / headers(可选对象)/ body(可选,提供时以 POST + JSON 发出)→ 原始 body 字符串
815
948
  async function proxyFetchText(proxyUrl, referer, extraHeaders, body) {
@@ -859,12 +992,23 @@ export function createEngine(env) {
859
992
  const VERSION_UPDATE_ANCHOR = { h: 4, mi: 0 };
860
993
 
861
994
  // 解析单个时间 → {ts, text};无法解析返回 {ts:null, text:null}
862
- 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());
863
1007
  const m = String(s).match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
864
1008
  if (m) {
865
1009
  const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]), h = Number(m[4]), mi = Number(m[5]);
866
1010
  const text = `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
867
- return { ts: new Date(y, mo - 1, d, h, mi).getTime(), text };
1011
+ return { ts: mkTs(y, mo, d, h, mi), text };
868
1012
  }
869
1013
  // 回退:`YYYY/MM/DD <版本更新后>`(日期 + 版本标签,缺时分)→ 按锚点时刻补全。
870
1014
  // 注意只认**带日期前缀**的这种;纯标签(`4.6版本更新后`)保持 null —— 那种确实
@@ -874,21 +1018,22 @@ export function createEngine(env) {
874
1018
  const y = Number(v[1]), mo = Number(v[2]), d = Number(v[3]);
875
1019
  const { h, mi } = VERSION_UPDATE_ANCHOR;
876
1020
  const text = `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
877
- return { ts: new Date(y, mo - 1, d, h, mi).getTime(), text };
1021
+ return { ts: mkTs(y, mo, d, h, mi), text };
878
1022
  }
879
1023
  return { ts: null, text: null };
880
1024
  }
881
1025
 
882
1026
  // 时间段 → startTs/endTs + 统一文本 mm-dd hh:mm ~ mm-dd hh:mm(无法解析的一侧保留原文)
883
- function parseRange(raw) {
1027
+ // `tz` 同 parseTime:源站墙钟时区(可选,不传 = 本机时区)
1028
+ function parseRange(raw, tz) {
884
1029
  const t = stripTags(raw);
885
1030
  const parts = t.split(/~/).map((x) => x.trim());
886
1031
  if (parts.length < 2) {
887
- const p = parseTime(parts[0]);
1032
+ const p = parseTime(parts[0], tz);
888
1033
  return { startTs: p.ts, endTs: null, startText: p.text, endText: null, raw: p.text ?? t };
889
1034
  }
890
- const a = parseTime(parts[0]);
891
- const b = parseTime(parts[1]);
1035
+ const a = parseTime(parts[0], tz);
1036
+ const b = parseTime(parts[1], tz);
892
1037
  return {
893
1038
  startTs: a.ts, endTs: b.ts,
894
1039
  startText: a.text ?? parts[0], endText: b.text ?? parts[1],
@@ -910,7 +1055,7 @@ export function createEngine(env) {
910
1055
  }
911
1056
 
912
1057
  // bwiki 通用:解析所有含「时间+版本」的卡池表(原神/星铁/绝区零)
913
- function parseAllBwiki(html) {
1058
+ function parseAllBwiki(html, tz) {
914
1059
  const out = [];
915
1060
  const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
916
1061
  for (const t of tables) {
@@ -930,7 +1075,7 @@ export function createEngine(env) {
930
1075
  const timeM = body.match(/<th[^>]*>\s*时间\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
931
1076
  const charM = body.match(/<th[^>]*>\s*(?:5星角色|S级代理人|6星干员|5星干员)\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
932
1077
  if (!timeM) continue;
933
- const range = parseRange(timeM[1]);
1078
+ const range = parseRange(timeM[1], tz);
934
1079
  out.push({
935
1080
  banner,
936
1081
  roles: charM ? stripTags(charM[1]) : "",
@@ -944,7 +1089,7 @@ export function createEngine(env) {
944
1089
  // 方舟:解析「干员轮换卡池」(标准寻访/当期轮换池)所有数据行。
945
1090
  // 该表行结构:序号 | 寻访页面(title=寻访模拟/干员轮换卡池N) | 开启时间 | 特定干员(6星) | 特定干员(5星)。
946
1091
  // 兼容历史「限时寻访」表(寻访页面|开启时间|特定干员6星|特定干员5星&4星)作为兜底。
947
- function parseArknights(html) {
1092
+ function parseArknights(html, tz) {
948
1093
  const out = [];
949
1094
  // 标准(干员轮换卡池N)+ 中坚(中坚甄选N / 中坚干员轮换卡池N):
950
1095
  // 行内 title="寻访模拟/<池名>"。**档位按池名判定且中坚优先**——「中坚干员轮换卡池74」
@@ -960,7 +1105,7 @@ export function createEngine(env) {
960
1105
  const tier = /中坚/.test(banner) ? "中坚" : "标准";
961
1106
  const roles = [...((tds[3] || "") + (tds[4] || "")).matchAll(/<a[^>]*title="([^"]+)"/g)]
962
1107
  .map((m) => m[1]).filter(Boolean);
963
- const range = parseRange(tds[2]);
1108
+ const range = parseRange(tds[2], tz);
964
1109
  out.push({ tier, banner, roles: roles.join("、"), ...range, isMain: true });
965
1110
  }
966
1111
  // 限时(联合行动/限定):解析「限时寻访」表
@@ -980,7 +1125,7 @@ export function createEngine(env) {
980
1125
  ? [...tds[2].matchAll(/<a[^>]*href="\/w\/[^"]*"[^>]*title="([^"]*)"/g)]
981
1126
  .map((m) => m[1].trim()).filter(Boolean).join("、")
982
1127
  : "";
983
- const range = parseRange(tds[1]);
1128
+ const range = parseRange(tds[1], tz);
984
1129
  out.push({ tier: "限时", banner, roles, ...range, isMain: true });
985
1130
  }
986
1131
  }
@@ -991,8 +1136,8 @@ export function createEngine(env) {
991
1136
  // 明日方舟当期卡池:**外显与悬停共用同一份"当期池"列表**(不再各挑一个)——
992
1137
  // 外显按档位优先(限时 > 标准 > 中坚)、档内先结束者优先;悬停走统一的 buildPoolHover,
993
1138
  // 与其它游戏同格式(每池『池名:角色』+ 时间行、同窗口合并时间、结束时间升序)。
994
- function selectArknights(html, now = nowMs()) {
995
- const items = parseArknights(html);
1139
+ function selectArknights(html, now = nowMs(), tz) {
1140
+ const items = parseArknights(html, tz);
996
1141
  fillMissingStarts(items);
997
1142
  const tiers = ["限时", "标准", "中坚"];
998
1143
  const rank = (it) => {
@@ -1101,7 +1246,7 @@ export function createEngine(env) {
1101
1246
  // 方舟卡池默认抓取器:官方公告 CMS 优先(当期限时/联动寻访),
1102
1247
  // 官方无当期寻访公告(常规轮换周)或官方失败时自动回退 PRTS 卡池一览(逻辑同 selectArknights)。
1103
1248
  // 设置页手动切到 PRTS 备选源时则走 GACHA_FETCHERS["arknights-prts"],不经本函数。
1104
- async function fetchArknightsGacha(url, signal, now = nowMs()) {
1249
+ async function fetchArknightsGacha(url, signal, tz, now = nowMs()) {
1105
1250
  try {
1106
1251
  const official = await fetchArknightsOfficialPools(signal, now);
1107
1252
  if (official.length > 0) {
@@ -1131,7 +1276,7 @@ export function createEngine(env) {
1131
1276
  const json = await res.json();
1132
1277
  const html = json?.parse?.text;
1133
1278
  if (typeof html !== "string") throw new Error("bad-json");
1134
- const d = selectArknights(html, now);
1279
+ const d = selectArknights(html, now, tz);
1135
1280
  return d ? { ...d } : null;
1136
1281
  }
1137
1282
 
@@ -1204,21 +1349,33 @@ export function createEngine(env) {
1204
1349
  }
1205
1350
 
1206
1351
  // MM-DD HH:MM(同年窗口用)
1207
- 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);
1208
1361
  const d = new Date(ts);
1209
- 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")}`;
1210
1367
  }
1211
1368
 
1212
1369
  // YYYY-MM-DD HH:MM(跨年窗口用:避免"05-15 16:00 ~ 05-15 03:59"看着像结束早于开始)
1213
- function fmtYmdHm(ts) {
1214
- const d = new Date(ts);
1215
- 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")}`;
1216
1373
  }
1217
1374
 
1218
1375
  // 窗口起止文本:两端同一年 → MM-DD;跨年 → 两端都带年份
1219
- function fmtWindow(startTs, endTs) {
1220
- const sameYear = new Date(startTs).getFullYear() === new Date(endTs).getFullYear();
1221
- 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)}`;
1222
1379
  }
1223
1380
 
1224
1381
  // 长期/常驻玩法判定:声明窗口超过该天数的不当作"当期活动"(外显与悬停共用,①)。
@@ -1359,7 +1516,7 @@ export function createEngine(env) {
1359
1516
  // 每一组 = 该组 data-start/data-end 之后、到下一组之前的那段里的「共鸣者/xxx」。
1360
1517
  // 旧实现取页面第一组时间 + 整页前 6 个角色名 → 备选/兜底源会显示"过期档期 + 跨池混入的角色",
1361
1518
  // 且不报任何失败(实测该页当前只有一组已过期计时器)。没有覆盖当前的组 → 返回 null(未公布)。
1362
- function parseWuwaPool(html, now = nowMs()) {
1519
+ function parseWuwaPool(html, now = nowMs(), tz) {
1363
1520
  const text = String(html || "");
1364
1521
  const marks = [...text.matchAll(/data-start="([^"]+)"\s+data-end="([^"]+)"/g)];
1365
1522
  // 页面拿到了却一个计时器都没有 → 汇总页改版(抛错让面板显示"卡池失败"),
@@ -1367,8 +1524,8 @@ export function createEngine(env) {
1367
1524
  if (marks.length === 0) throw new Error("wuwa-pool-no-timer");
1368
1525
  for (let i = 0; i < marks.length; i++) {
1369
1526
  const m = marks[i];
1370
- const start = parseTime(m[1]);
1371
- const end = parseTime(m[2]);
1527
+ const start = parseTime(m[1], tz);
1528
+ const end = parseTime(m[2], tz);
1372
1529
  if (start.ts == null || end.ts == null) continue;
1373
1530
  if (!(start.ts <= now && now <= end.ts)) continue;
1374
1531
  const seg = text.slice(m.index + m[0].length, i + 1 < marks.length ? marks[i + 1].index : text.length);
@@ -1384,9 +1541,14 @@ export function createEngine(env) {
1384
1541
  return null;
1385
1542
  }
1386
1543
 
1387
- // 鸣潮官方公告解析:从全量公告(game/activity/recommend)中取"覆盖当前时刻"的「角色活动唤取」
1388
- // 公告形如:tabTitle="[身赴三途]角色活动唤取",content 内含"✦活动时间✦ 2026年9月10日10:00 ~ 2026年9月29日11:59"
1389
- function parseWuwaNotice(list, now = nowMs()) {
1544
+ // 鸣潮官方公告解析:从全量公告(game/activity/recommend)中取"覆盖当前时刻"的「角色活动唤取」。
1545
+ //
1546
+ // ⚠️ 时间**必须用 JSON 里的绝对时间戳** `startTimeMs` / `endTimeMs`,不要再去解析正文。
1547
+ // 正文写的是 `✦活动时间✦ 3.7版本更新后 ~ 2026年10月22日09:59(服务器时间)` ——
1548
+ // 起始端是**版本标签**而非绝对日期,旧实现用 `(\d{4})年(\d{1,2})月…` 匹配整段 → 恒失败 →
1549
+ // 卡池侧返回 null(2026-10-01 实测:官方 JSON 已带 3 个在开角色池,却显示"未公布")。
1550
+ // 现在源站直接给了时间戳,比解析正文更准,也不受措辞漂移影响。
1551
+ function parseWuwaNotice(list, now = nowMs(), tz) {
1390
1552
  const groups = [list?.game, list?.activity, list?.recommend].filter(Array.isArray);
1391
1553
  const stripH = (s) => String(s || "")
1392
1554
  .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
@@ -1396,46 +1558,93 @@ export function createEngine(env) {
1396
1558
  const pools = [];
1397
1559
  for (const arr of groups) {
1398
1560
  for (const it of arr) {
1399
- const title = stripH(it.tabTitle || it.title || "");
1400
- if (!/活动唤取/.test(title)) continue;
1401
- const text = stripH(it.content || "");
1402
- const m = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})\s*[~~-]\s*(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/);
1403
- if (!m) continue;
1404
- const sTs = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
1405
- const eTs = new Date(+(m[6] || m[1]), +m[7] - 1, +m[8], +m[9], +m[10]).getTime();
1561
+ // 标题可能是 "[但愿长圆如此夜]\n角色活动唤取" / "「玉阙玄华」武器活动唤取"
1562
+ const title = stripH(it.tabTitle || it.title || "").replace(/\s+/g, " ").trim();
1563
+ const isChar = /角色活动唤取/.test(title);
1564
+ const isWeapon = /武器活动唤取/.test(title);
1565
+ if (!isChar && !isWeapon) continue;
1566
+ const sTs = Number(it.startTimeMs), eTs = Number(it.endTimeMs);
1567
+ if (!Number.isFinite(sTs) || !Number.isFinite(eTs)) continue;
1406
1568
  if (sTs > now || eTs < now) continue; // 只要当期覆盖
1407
1569
  const name = title
1408
1570
  .replace(/\s*(?:角色|武器)活动唤取\s*$/, "")
1409
1571
  .replace(/^[\[【「]\s*/, "")
1410
1572
  .replace(/\s*[\]】」]$/, "")
1411
1573
  .trim();
1412
- const upPart = text.split(/✦\s*活动时间/)[0] || "";
1413
- const ups = [...upPart.matchAll(/[「【]([^」】]+)[」】]/g)].map((x) => x[1].trim()).filter(Boolean);
1414
- pools.push({ name, isChar: /角色活动唤取/.test(title), roles: ups.join("、"), startTs: sTs, endTs: eTs });
1574
+ if (!name) continue;
1575
+ // 类型名(外显用):标题尾部那一段,如 `角色活动唤取` / `武器活动唤取`
1576
+ const type = isChar ? "角色活动唤取" : "武器活动唤取";
1577
+ // 角色名:正文开头那句「活动期间,5星角色「心」,4星角色「卜灵」、「桃祈」、「釉瑚」唤取概率限时提升!」
1578
+ // 用 [^。!?\n]+ 限在一句内,避免把后面「唤取说明」里的角色也带进来
1579
+ const text = stripH(it.content || "");
1580
+ const upM = text.match(/活动期间,([^。!?\n]+?)唤取概率限时提升/);
1581
+ const roles = upM
1582
+ ? [...new Set([...upM[1].matchAll(/[「【]([^」】]+)[」】]/g)].map((x) => x[1].trim()).filter(Boolean))].join("、")
1583
+ : "";
1584
+ pools.push({ name, type, isChar, roles, startTs: sTs, endTs: eTs });
1415
1585
  }
1416
1586
  }
1417
1587
  const cur = pools.filter((p) => p.isChar && p.name);
1418
1588
  if (cur.length === 0) return null;
1419
- const first = cur[0];
1420
- const roles = [...new Set(cur.flatMap((p) => p.roles.split("、")).filter(Boolean))].join("、");
1589
+ // 外显与窗口取**结束最早**的那个池(与 selectCurrent 的 first 同口径)。
1590
+ const first = cur.slice().sort((a, b) => a.endTs - b.endTs)[0];
1591
+ // 角色:**先拆成单个名字再去重**,然后合并。
1592
+ // ⚠️ 别写成 `new Set(cur.map(p => p.roles))` —— 那样比较的是"整串"(各池的 4★ 名单相同
1593
+ // 但 5★ 不同 → 整串不同 → 重复留下)。拆开才能把三池共有的 4★ 去成一份。
1594
+ // 顺序 = 各池依次展开(5★ 在前、4★ 共有名在后),去重后即 `心、千咲、尤诺、卜灵、桃祈、釉瑚`。
1595
+ const roles = [...new Set(cur.flatMap((p) => p.roles.split("、").map((s) => s.trim()).filter(Boolean)))].join("、");
1421
1596
  const fmt = (ts) => {
1422
1597
  const d = new Date(ts);
1423
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")}`;
1424
1599
  };
1425
- const raw = fmtWindow(first.startTs, first.endTs);
1426
- // 悬停:每池"池名:角色"一行 + 时间一行;各池窗口相同则时间只在末尾写一遍;按结束时间升序
1427
- const bannerHover = buildPoolHover(cur.map((p) => ({
1600
+ const raw = fmtWindow(first.startTs, first.endTs, tz);
1601
+ // 悬停:首行=卡池类型(与卡片外显同源),随后每池"池名:角色"一行;窗口相同则时间只在末尾写一遍;
1602
+ // 按结束时间升序。**类型行只在本源补**,不动全站共用的 buildPoolHover(它按约定对 0/1 池返回 "")。
1603
+ const poolHover = buildPoolHover(cur.map((p) => ({
1428
1604
  name: p.name,
1429
1605
  label: `${p.name}\uFF1A${p.roles || "-"}`,
1430
1606
  startTs: p.startTs,
1431
1607
  endTs: p.endTs
1432
1608
  })));
1433
- return { banner: first.name, roles, bannerDates: raw, bannerDatesRaw: raw, startTs: first.startTs, endTs: first.endTs, bannerHover };
1609
+ const bannerHover = poolHover ? `${first.type}\n${poolHover}` : "";
1610
+ // **外显用卡池类型**(`角色活动唤取`),不用某一个池名:
1611
+ // 角色是多池合并的,若外显挂"但愿长圆如此夜",卡片就成了"标题只说一个池、角色却是三个池的合成"。
1612
+ // 与用户 2026-09-30 定的统一口径一致(国服显示 `限时限定招募`、异环显示 `限定棋盘`)。
1613
+ // 具体池名在悬停里逐条列出,不丢信息。
1614
+ return { banner: first.type, roles, bannerDates: raw, bannerDatesRaw: raw, startTs: first.startTs, endTs: first.endTs, bannerHover };
1615
+ }
1616
+
1617
+ // 鸣潮官方活动解析:同一份全量公告的 `recommend` 组里,`tag === 7` 是卡池、**`tag === 5` 是限时活动**。
1618
+ // 活动条目形如 tabTitle="[团团勇者大乱斗]休闲活动",同样带绝对时间戳。
1619
+ // 这是 2026-10-01 起鸣潮活动的**默认源**——Bwiki 活动日历页已停更(最新一条结束于 2026/9/29),
1620
+ // 而官方源有当期 3.7 的活动,且**与卡池是同一条 URL**、同一次请求即可拿到两侧数据。
1621
+ function parseWuwaRecommendEvents(list, now = nowMs(), tz) {
1622
+ const arr = Array.isArray(list?.recommend) ? list.recommend : [];
1623
+ const stripH = (s) => String(s || "")
1624
+ .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
1625
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
1626
+ .replace(/[ \t]+/g, " ").replace(/\n\s*\n+/g, "\n").trim();
1627
+ const events = [];
1628
+ for (const it of arr) {
1629
+ if (Number(it.tag) !== 5) continue; // 7=卡池;5=限时活动
1630
+ const name = stripH(it.tabTitle || "").replace(/\s+/g, " ").trim();
1631
+ if (!name) continue;
1632
+ const sTs = Number(it.startTimeMs), eTs = Number(it.endTimeMs);
1633
+ if (!Number.isFinite(sTs) || !Number.isFinite(eTs)) continue;
1634
+ if (sTs > now || eTs < now) continue;
1635
+ events.push({ name, cat: "", startTs: sTs, endTs: eTs });
1636
+ }
1637
+ if (events.length === 0) return null;
1638
+ const sorted = sortEventItems(events);
1639
+ if (sorted.length === 0) return null;
1640
+ const primary = pickEventPrimary(sorted) || sorted[0];
1641
+ const dates = fmtWindow(primary.startTs, primary.endTs, tz);
1642
+ return { banner: primary.name, bannerDates: dates, bannerDatesRaw: dates, eventHover: buildEventHover(sorted) };
1434
1643
  }
1435
1644
 
1436
1645
  // 鸣潮卡池默认抓取器:官方公告(entrypoint → 目录 → zh-Hans.json 全量)优先;
1437
1646
  // 无当期公告 / 抓取失败 → 自动回退 Bwiki 角色轮换池(逻辑同 parseWuwaPool)
1438
- async function fetchWuwaGacha(entryUrl, signal, now = nowMs()) {
1647
+ async function fetchWuwaGacha(entryUrl, signal, tz, now = nowMs()) {
1439
1648
  try {
1440
1649
  const ref = "https://aki-gm-resources.aki-game.com/";
1441
1650
  const ej = await proxyFetchJson(entryUrl, ref);
@@ -1444,8 +1653,18 @@ export function createEngine(env) {
1444
1653
  let list = null;
1445
1654
  try { list = await proxyFetchJson(dir + "zh-Hans.json", ref); } catch { list = null; }
1446
1655
  if (!list || typeof list !== "object") list = await proxyFetchJson(dir + "notice.json", ref);
1447
- const d = parseWuwaNotice(list, now);
1448
- if (d) return d;
1656
+ // **同一份 JSON 里卡池(tag=7)与活动(tag=5)都在** → 顺带把活动字段也返回。
1657
+ // 这是"统一来源注册表"里写明的复用契约:**两侧是同一条 URL** 且卡池载荷带 event 字段时,
1658
+ // refresh 不再为活动侧另抓一次(否则每轮会向同一条 URL 重复发一次请求)。
1659
+ // ⚠️ 我重写解析时一度只返回卡池字段,导致鸣潮每轮请求从 1 次变成 2 次 —— 已补回。
1660
+ // 活动侧仍保留自己的抓取器(fetchWuwaEventsOfficial):卡池侧失败、或用户在设置里
1661
+ // 单独选活动来源时,活动侧要能自己抓、自己报错(解耦不变)。
1662
+ const ev = parseWuwaRecommendEvents(list, now, tz);
1663
+ const evFields = ev
1664
+ ? { event: ev.banner, eventDates: ev.bannerDates || "", eventDatesRaw: ev.bannerDatesRaw || ev.bannerDates || "", eventHover: ev.eventHover || "" }
1665
+ : {};
1666
+ const d = parseWuwaNotice(list, now, tz);
1667
+ if (d) return { ...d, ...evFields };
1449
1668
  } catch { /* 官方失败 → Bwiki 备选 */ }
1450
1669
  const apiUrl = WUWA_BWIKI_URL + (WUWA_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
1451
1670
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
@@ -1453,14 +1672,33 @@ export function createEngine(env) {
1453
1672
  const json = await res.json();
1454
1673
  const html = json?.parse?.text;
1455
1674
  if (typeof html !== "string") throw new Error("bad-json");
1456
- return parseWuwaPool(html);
1675
+ return parseWuwaPool(html, nowMs(), tz);
1676
+ }
1677
+
1678
+ // 鸣潮活动默认抓取器:抓同一份官方公告,取 `recommend` 组里 tag=5 的限时活动。
1679
+ // 与卡池侧是**同一条 URL**(entrypoint.json),所以 refresh 的复用会让两侧共用一次请求。
1680
+ async function fetchWuwaEventsOfficial(entryUrl, _signal, tz) {
1681
+ const ref = "https://aki-gm-resources.aki-game.com/";
1682
+ const ej = await proxyFetchJson(entryUrl, ref);
1683
+ const contentUrl = Array.isArray(ej?.contentUrl) ? ej.contentUrl[0] : "";
1684
+ const dir = contentUrl ? contentUrl.replace(/[^/]*$/, "") : String(entryUrl).replace(/[^/]*$/, "");
1685
+ const list = await proxyFetchJson(dir + "zh-Hans.json", ref);
1686
+ if (!list || typeof list !== "object") throw new Error("wuwa-event-bad-json");
1687
+ const d = parseWuwaRecommendEvents(list, nowMs(), tz);
1688
+ if (!d) return null;
1689
+ return {
1690
+ event: d.banner,
1691
+ eventDates: d.bannerDates || "",
1692
+ eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "",
1693
+ eventHover: d.eventHover || ""
1694
+ };
1457
1695
  }
1458
1696
 
1459
1697
  // 绝区零官网公告解析:从公告频道(iChanId=279)取「X.Y版本限时频段(上/下期)」,选覆盖当前时刻的一期。
1460
1698
  // 公告形如:sIntro="本期代理人与音擎调频活动时间为:3.2版本更新后 ~ 2026/09/30 11:59",
1461
1699
  // sContent 内含「活动期间,限定S级代理人[克拉蕾(电·锋御)]、[南宫羽(以太·击破)]…」。
1462
1700
  // 起点为"版本更新后"时,用同版本「更新公告」的 dtStartTime 补全;「独家重映/音擎回响」自选段跳过。
1463
- function parseZzzFreq(payload, now = nowMs()) {
1701
+ function parseZzzFreq(payload, now = nowMs(), tz) {
1464
1702
  const list = Array.isArray(payload?.data?.list) ? payload.data.list : [];
1465
1703
  const clean = (s) => stripTags(s);
1466
1704
  // 版本更新公告 → "X.Y版本更新后"的起点;同时抽取「S级代理人[X] → 「Y」频段」对应表
@@ -1475,7 +1713,7 @@ export function createEngine(env) {
1475
1713
  const vm = t.match(/(\d+\.\d+)\s*版本/);
1476
1714
  if (!vm) continue;
1477
1715
  if (/更新(?:公告|通知)/.test(t) && !/预下载|预约|前瞻|预抽/.test(t)) {
1478
- const p = parseTime(it.dtStartTime);
1716
+ const p = parseTime(it.dtStartTime, tz);
1479
1717
  if (p.ts != null && verStart[vm[1]] == null) verStart[vm[1]] = p;
1480
1718
  }
1481
1719
  const text = clean(it.sIntro) + " " + clean(it.sContent);
@@ -1505,7 +1743,7 @@ export function createEngine(env) {
1505
1743
  while ((m = re.exec(text)) !== null) {
1506
1744
  marks.push({ start: m[1], end: m[2], from: m.index, to: m.index + m[0].length, after: /版本更新后/.test(m[1]) });
1507
1745
  }
1508
- const created = parseTime(it.dtCreateTime).ts ?? 0;
1746
+ const created = parseTime(it.dtCreateTime, tz).ts ?? 0;
1509
1747
  for (let i = 0; i < marks.length; i++) {
1510
1748
  const seg = text.slice(marks[i].to, i + 1 < marks.length ? marks[i + 1].from : text.length);
1511
1749
  // 该时间窗对应的「限定S级代理人」句(排除独家重映/音擎回响的自选说明)
@@ -1515,8 +1753,8 @@ export function createEngine(env) {
1515
1753
  if (!roleM) continue;
1516
1754
  const roles = [...roleM[1].matchAll(/[\[【]([^\]】]+)[\]】]/g)].map((x) => normRole(x[1].trim())).join("、");
1517
1755
  if (!roles) continue;
1518
- const sp = marks[i].after ? (verStart[ver] || { ts: null, text: null }) : parseTime(marks[i].start);
1519
- 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);
1520
1758
  if (ep.ts == null) continue;
1521
1759
  pools.push({
1522
1760
  ver, part, roles,
@@ -1554,11 +1792,21 @@ export function createEngine(env) {
1554
1792
  }
1555
1793
 
1556
1794
  // 绝区零卡池默认抓取器:官网公告优先;无当期频段公告 / 抓取失败 → 自动回退 Bwiki 往期调频
1557
- async function fetchZzzGacha(listUrl, signal, now = nowMs()) {
1795
+ async function fetchZzzGacha(listUrl, signal, tz, now = nowMs()) {
1558
1796
  try {
1559
1797
  const payload = await proxyFetchJson(listUrl, "https://zzz.mihoyo.com/");
1560
- const d = parseZzzFreq(payload, now);
1561
- 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 };
1562
1810
  } catch { /* 官方失败 → Bwiki 备选 */ }
1563
1811
  const apiUrl = ZZZ_BWIKI_URL + (ZZZ_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
1564
1812
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
@@ -1577,7 +1825,7 @@ export function createEngine(env) {
1577
1825
  // 由 sortEventItems / pickEventPrimary 的既有规则自然沉到外显与悬停的最后,不跳过、不丢弃。
1578
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;
1579
1827
 
1580
- function parseZzzEventsOfficial(payload, now = nowMs()) {
1828
+ function parseZzzEventsOfficial(payload, now = nowMs(), tz) {
1581
1829
  const list = Array.isArray(payload?.data?.list) ? payload.data.list : [];
1582
1830
  const clean = (s) => stripTags(s);
1583
1831
  // 版本起点表:取该版本的「更新公告」发布时间。
@@ -1589,7 +1837,7 @@ export function createEngine(env) {
1589
1837
  const vm = title.match(/(\d+\.\d+)\s*版本/);
1590
1838
  if (!vm || !/更新(?:公告|通知)/.test(title)) continue;
1591
1839
  if (/预下载|预约|前瞻|预抽/.test(title)) continue;
1592
- const ts = parseTime(it.dtStartTime).ts;
1840
+ const ts = parseTime(it.dtStartTime, tz).ts;
1593
1841
  if (ts != null && verStart[vm[1]] == null) verStart[vm[1]] = ts;
1594
1842
  }
1595
1843
  const versions = Object.keys(verStart).sort((a, b) => verStart[a] - verStart[b]);
@@ -1604,7 +1852,7 @@ export function createEngine(env) {
1604
1852
  if (verStart[sv[1]] == null) return null; // 该版本尚未开始 → 活动还没上线
1605
1853
  startTs = verStart[sv[1]];
1606
1854
  } else {
1607
- const p = parseTime(startText);
1855
+ const p = parseTime(startText, tz);
1608
1856
  if (p.ts == null) return null;
1609
1857
  startTs = p.ts;
1610
1858
  }
@@ -1616,7 +1864,7 @@ export function createEngine(env) {
1616
1864
  const later = versions.find((v) => verStart[v] > verStart[ev[1]]);
1617
1865
  if (later != null) endTs = verStart[later] - 60000;
1618
1866
  } else {
1619
- const p = parseTime(endText);
1867
+ const p = parseTime(endText, tz);
1620
1868
  if (p.ts == null) return null;
1621
1869
  endTs = p.ts;
1622
1870
  }
@@ -1641,7 +1889,7 @@ export function createEngine(env) {
1641
1889
  const active = sortEventItems([...byName.values()]);
1642
1890
  if (active.length === 0) return null;
1643
1891
  const primary = pickEventPrimary(active) || active[0];
1644
- 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 || "");
1645
1893
  return {
1646
1894
  event: primary.name,
1647
1895
  eventDates: dates,
@@ -1651,14 +1899,14 @@ export function createEngine(env) {
1651
1899
  };
1652
1900
  }
1653
1901
 
1654
- async function fetchZzzEventsOfficial(listUrl, signal) {
1902
+ async function fetchZzzEventsOfficial(listUrl, signal, tz) {
1655
1903
  const payload = await proxyFetchJson(listUrl, "https://zzz.mihoyo.com/");
1656
- return parseZzzEventsOfficial(payload);
1904
+ return parseZzzEventsOfficial(payload, nowMs(), tz);
1657
1905
  }
1658
1906
 
1659
1907
  // 鸣潮:活动日历页 → font-size:17px 标题 + font-size:11px 时间,选当期
1660
1908
  // 注意:复用 selectCurrent 需要 isMain 字段(该函数按 isMain 过滤主池)
1661
- function parseWuwaCalendar(html) {
1909
+ function parseWuwaCalendar(html, tz) {
1662
1910
  const items = [];
1663
1911
  const re = /font-size:17px[^>]*>\s*<p>\s*([\s\S]*?)\s*<\/p>([\s\S]*?)(?=font-size:17px|$)/g;
1664
1912
  let m;
@@ -1668,7 +1916,7 @@ export function createEngine(env) {
1668
1916
  const timeM = m[2].match(/font-size:11px[^>]*>\s*<p>\s*([\s\S]*?)\s*<\/p>/);
1669
1917
  const timeText = timeM ? stripTags(timeM[1]) : "";
1670
1918
  if (!/20\d{2}\//.test(timeText)) continue;
1671
- const range = parseRange(timeText);
1919
+ const range = parseRange(timeText, tz);
1672
1920
  items.push({ banner: name, name, ...range, isMain: true });
1673
1921
  }
1674
1922
  const now = nowMs();
@@ -1678,7 +1926,7 @@ export function createEngine(env) {
1678
1926
  if (active.length === 0) return null;
1679
1927
  // 外显:类别优先(战斗/高难类优先),同级内结束时间升序(③);悬停仍按 endTs 升序全量
1680
1928
  const primary = pickEventPrimary(active) || active[0];
1681
- 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 || "");
1682
1930
  return { banner: primary.name, roles: "", bannerDates: dates, bannerDatesRaw: primary.raw || dates, eventHover: buildEventHover(active) };
1683
1931
  }
1684
1932
 
@@ -1806,12 +2054,14 @@ export function createEngine(env) {
1806
2054
  // 返回值三态:数据对象 / null(**结构在**但没有覆盖当前时刻的期次 → 未公布)/ undefined
1807
2055
  // (这份 JS 里**根本没有**卡池数据结构 → 交给调用方决定:多 chunk 时继续找下一个,
1808
2056
  // 全部 chunk 都没有则说明页面改版 → 报错,而不是伪装成"未公布")。
1809
- function currentFromCanmoe(js, now) {
2057
+ function currentFromCanmoe(js, now, tz) {
1810
2058
  now = now || nowMs();
1811
2059
  let sawStructure = false;
1812
2060
  const fmt = (iso) => {
1813
- const d = new Date(iso);
1814
- 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")}`;
1815
2065
  };
1816
2066
  // **统一规则(§49)**:当前时刻落在持续期间内的卡池**全部合并**外显。
1817
2067
  // 旧实现是"两个分支各自命中第一个就 return" → 只显示一个池,同期的另一个池看不到
@@ -1910,8 +2160,8 @@ export function createEngine(env) {
1910
2160
  // 兼容旧调用:parseCanmoe / parseCanmoeLoose 均走统一的"当期选择"逻辑(now 可注入)。
1911
2161
  // 这两个是**给通用解析(自定义条目/自定义地址)用的宽松包装**:把 undefined 归一成 null,
1912
2162
  // 即"读不出来 → 未公布",不在这里抛错(用户自定义地址读不出内容是常态,不该报成源站故障)。
1913
- function parseCanmoe(js, now = nowMs()) { const d = currentFromCanmoe(js, now); return d === void 0 ? null : d; }
1914
- 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); }
1915
2165
 
1916
2166
  // 终末地(canmoe 经 host 代理):页面 HTML → 定位 BannerCalendar chunk → 抓 chunk JS → 窗口匹配当期
1917
2167
  // canmoe 无 CORS 头,两步都经 host 代理(referer 用页面 origin 满足反爬)
@@ -1921,7 +2171,7 @@ export function createEngine(env) {
1921
2171
  // · 有覆盖当前时刻的期次 → 返回数据;
1922
2172
  // · 拿到 JS 且里面有卡池结构、但没有覆盖当前的期次 → return null(未公布);
1923
2173
  // · 页面/所有 chunk 里都找不到卡池结构(或 chunk 全抓失败)→ **抛错**(面板显示"卡池失败")。
1924
- async function fetchCanmoeEndfield(pageUrl, now = nowMs()) {
2174
+ async function fetchCanmoeEndfield(pageUrl, _signal, tz, now = nowMs()) {
1925
2175
  const html = await proxyFetchText(pageUrl, "https://end.canmoe.com/");
1926
2176
  const chunks = nextJsChunkUrls(html, "BannerCalendar", pageUrl);
1927
2177
  if (chunks.length === 0) throw new Error("canmoe-no-chunk"); // 页面拿到了但没有数据块链接 = 改版
@@ -1935,7 +2185,7 @@ export function createEngine(env) {
1935
2185
  continue;
1936
2186
  }
1937
2187
  fetchedAny = true;
1938
- const d = currentFromCanmoe(js, now);
2188
+ const d = currentFromCanmoe(js, now, tz);
1939
2189
  if (d === void 0) continue; // 这份 chunk 里没有卡池结构 → 看下一个
1940
2190
  sawStructure = true;
1941
2191
  if (d) {
@@ -2016,7 +2266,7 @@ export function createEngine(env) {
2016
2266
  }
2017
2267
 
2018
2268
  // 异环(ldshop 经 host 代理):抓页面 → 解析卡池表 → 选当期
2019
- async function fetchLdshopNte(pageUrl) {
2269
+ async function fetchLdshopNte(pageUrl, _signal, tz) {
2020
2270
  const html = await proxyFetchText(pageUrl, "https://www.ldshop.gg/");
2021
2271
  const pools = parseLdshopPools(html);
2022
2272
  if (pools.length === 0) return null;
@@ -2050,7 +2300,7 @@ export function createEngine(env) {
2050
2300
  // 取第一篇能解析出当期卡池/活动的正文;"不停服更新"不含新卡池,跳过。
2051
2301
  // 三态:有当期内容 → 数据;列表页有公告但都不含当期内容(或"不停服更新")→ null(未公布);
2052
2302
  // 列表页**一条公告链接都没有** → 抛错(官网列表改版,让面板显示"卡池失败"而不是"未公布")。
2053
- async function fetchNteWanmei(listUrl, signal) {
2303
+ async function fetchNteWanmei(listUrl, signal, tz) {
2054
2304
  const ref = "https://yh.wanmei.com/";
2055
2305
  const seen = new Set();
2056
2306
  const queue = [listUrl];
@@ -2067,7 +2317,7 @@ export function createEngine(env) {
2067
2317
  if (!NTE_MAINT_RE.test(m[2]) || /不停服/.test(m[2])) continue;
2068
2318
  if (details >= NTE_MAX_DETAILS) return null; // 试读额度用完(此时必然已见到公告链接)
2069
2319
  details++;
2070
- 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);
2071
2321
  if (data) return data;
2072
2322
  }
2073
2323
  for (const u of nteNextPageUrls(listUrl, html, seen)) if (!queue.includes(u)) queue.push(u);
@@ -2134,7 +2384,7 @@ export function createEngine(env) {
2134
2384
  }
2135
2385
 
2136
2386
  // 解析官网公告正文 → 当期卡池(有「棋盘说明」的角色卡池,按**统一规则**合并)+ 当期活动(限时活动)
2137
- function parseNteWanmei(html) {
2387
+ function parseNteWanmei(html, tz) {
2138
2388
  const text = String(html || "")
2139
2389
  .replace(/<script[\s\S]*?<\/script>/gi, " ")
2140
2390
  .replace(/<style[\s\S]*?<\/style>/gi, " ")
@@ -2190,10 +2440,10 @@ export function createEngine(env) {
2190
2440
  // 依次尝试 bwiki 式「时间+版本」表、方舟式「限时寻访」表、GachaTracker 式日期表、
2191
2441
  // Next.js SPA(canmoe 等,页面无表格、数据在组件 chunk 里),选当期;
2192
2442
  // 全部失败返回 null(调用方按解析失败处理,不做可达性健康检查)
2193
- async function tryParseGenericGacha(url, signal) {
2443
+ async function tryParseGenericGacha(url, signal, tz) {
2194
2444
  const html = await fetchHtmlText(url, signal);
2195
2445
  const now = nowMs();
2196
- const cur = selectCurrent(parseAllBwiki(html), now) || selectCurrent(parseArknights(html), now);
2446
+ const cur = selectCurrent(parseAllBwiki(html, tz), now) || selectCurrent(parseArknights(html, tz), now);
2197
2447
  if (cur && cur.banner && cur.bannerDates) return cur;
2198
2448
  const gt = parseGachaTracker(html);
2199
2449
  if (gt && gt.banner && gt.bannerDates) return gt;
@@ -2214,7 +2464,7 @@ export function createEngine(env) {
2214
2464
 
2215
2465
  // 通用活动解析:扫描含「时间」(或「活动时间」)表头的表格,行内找时间与名称列,选当期
2216
2466
  // 起始为"版本更新后"等无日期文本时保留 startTs=null,由 selectCurrent 的 fillMissingStarts 补全
2217
- function collectGenericEvents(html) {
2467
+ function collectGenericEvents(html, tz) {
2218
2468
  const items = [];
2219
2469
  const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
2220
2470
  for (const t of tables) {
@@ -2238,7 +2488,7 @@ export function createEngine(env) {
2238
2488
  // 名字里偶发混进图片文件名残渣(如「文件:巡星之礼第二十六期.png 」)→ 去掉该前缀
2239
2489
  name = name.replace(/^文件:[^\s]*?\.(?:png|jpe?g|gif|webp|svg)\s*/i, "").trim();
2240
2490
  if (!name) continue;
2241
- const range = parseRange(time);
2491
+ const range = parseRange(time, tz);
2242
2492
  // 起始可为 null("版本更新后"),结束时间必须有效——**除非这行是永久活动**。
2243
2493
  // 原来这里一律 `endTs == null → continue`,把「…~永久」的行在采集阶段就丢了,
2244
2494
  // 下游连"它存在过"都看不到(表现为源站表里有、面板悬停里没有)。
@@ -2254,14 +2504,14 @@ export function createEngine(env) {
2254
2504
  }
2255
2505
 
2256
2506
  // 当期活动(外显取 selectCurrent 的那条,行为同旧版)
2257
- function parseGenericEvents(html) {
2258
- return selectCurrent(collectGenericEvents(html), nowMs());
2507
+ function parseGenericEvents(html, tz) {
2508
+ return selectCurrent(collectGenericEvents(html, tz), nowMs());
2259
2509
  }
2260
2510
 
2261
2511
  // Bwiki 卡池列载荷(原神/星铁):外显沿用 selectCurrent(同窗口主池角色合并、武器/光锥池不入选);
2262
2512
  // bannerHover 列出同期全部主池(每池"池名:角色"+时间;窗口相同则合并时间;结束时间升序)
2263
- function bwikiGachaPayload(html) {
2264
- const items = parseAllBwiki(html);
2513
+ function bwikiGachaPayload(html, tz) {
2514
+ const items = parseAllBwiki(html, tz);
2265
2515
  // 页面拿到了却连一行候选都没有 → wiki 表结构变了(抛错,面板显示"卡池失败");
2266
2516
  // 有候选但都不覆盖当前时刻 → 下面返回 null(未公布)。这两件事必须分开。
2267
2517
  if (items.length === 0) throw new Error("bwiki-gacha-no-table");
@@ -2286,8 +2536,8 @@ export function createEngine(env) {
2286
2536
  // 注意 selectCurrent 会就地补全缺失起点(fillMissingStarts),故这里只取快照自行排序,
2287
2537
  // 避免"版本更新后 ~ 未来"这类起点未给的行被补成未来起点而漏掉。
2288
2538
  // 只有 1 条时 buildEventHover 返回 "",由 UI 退回单条展示(兜底)。
2289
- function genericEventPayload(html) {
2290
- const items = collectGenericEvents(html);
2539
+ function genericEventPayload(html, tz) {
2540
+ const items = collectGenericEvents(html, tz);
2291
2541
  // 页面拿到了却连一行候选都没有 → 活动表结构变了(抛错 = "活动失败");
2292
2542
  // 有候选但当期没有覆盖现在的 → 下面返回 null(未公布)
2293
2543
  if (items.length === 0) throw new Error("bwiki-event-no-table");
@@ -2302,7 +2552,7 @@ export function createEngine(env) {
2302
2552
  const primary = pickEventPrimary(active) || active[0];
2303
2553
  return {
2304
2554
  event: primary.name,
2305
- 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 || ""),
2306
2556
  eventDatesRaw: primary.raw || "",
2307
2557
  eventHover: buildEventHover(active, permanent.length)
2308
2558
  };
@@ -2313,7 +2563,7 @@ export function createEngine(env) {
2313
2563
  // 注意 data-time 第一个值是页面缓存时刻(非开始时间),故开始以文本列为准。
2314
2564
  // 活动名带核心分类前缀("支线故事:墟·复刻"):分类取第三列 <a title="分类:XXX"> 链接,
2315
2565
  // 核心分类 = 排除"复刻活动"(修饰词)后的第一个。
2316
- function collectPrtsEvents(html) {
2566
+ function collectPrtsEvents(html, tz) {
2317
2567
  const items = [];
2318
2568
  const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
2319
2569
  for (const t of tables) {
@@ -2342,7 +2592,7 @@ export function createEngine(env) {
2342
2592
  }
2343
2593
  const label = coreCat ? `${coreCat}\uFF1A${name}` : name;
2344
2594
  // 开始:第一列文本("2026-08-22 04:00")
2345
- const st = parseTime(stripTags(tds[0]).trim());
2595
+ const st = parseTime(stripTags(tds[0]).trim(), tz);
2346
2596
  items.push({
2347
2597
  banner: label,
2348
2598
  cat: coreCat,
@@ -2355,13 +2605,13 @@ export function createEngine(env) {
2355
2605
  }
2356
2606
 
2357
2607
  // 当期活动(外显取 selectCurrent 的那条,行为同旧版)
2358
- function parsePrtsEvents(html) {
2359
- return selectCurrent(collectPrtsEvents(html), nowMs());
2608
+ function parsePrtsEvents(html, tz) {
2609
+ return selectCurrent(collectPrtsEvents(html, tz), nowMs());
2360
2610
  }
2361
2611
 
2362
2612
  // 活动列载荷:外显=排序第一条(最快结束的当期活动,③);eventHover=全部覆盖当前时刻的活动(同序)
2363
- function prtsEventPayload(html) {
2364
- const items = collectPrtsEvents(html);
2613
+ function prtsEventPayload(html, tz) {
2614
+ const items = collectPrtsEvents(html, tz);
2365
2615
  const snapshot = items.map((it) => ({ name: it.banner, cat: it.cat || "", startTs: it.startTs, endTs: it.endTs, raw: it.raw }));
2366
2616
  const now = nowMs();
2367
2617
  const active = sortEventItems(snapshot.filter((it) => it.endTs != null && it.endTs >= now && (it.startTs == null || it.startTs <= now)));
@@ -2370,7 +2620,7 @@ export function createEngine(env) {
2370
2620
  const primary = pickEventPrimary(active) || active[0];
2371
2621
  return {
2372
2622
  event: primary.name,
2373
- 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 || ""),
2374
2624
  eventDatesRaw: primary.raw || "",
2375
2625
  eventHover: buildEventHover(active)
2376
2626
  };
@@ -2395,7 +2645,7 @@ export function createEngine(env) {
2395
2645
  // <a class="a-link" href="...">Bedazzling Dawnstar Sign-In</a><br>(Version 1.4)<br>08/09/26 - 09/02/26
2396
2646
  // 日期为美式 MM/DD/YY;多个并行当期活动时选"结束最晚"(覆盖全部当期窗口)。
2397
2647
  // 无起止区间(只有开始日,如 "07/16")的条目跳过。
2398
- function parseGame8Events(html) {
2648
+ function parseGame8Events(html, tz) {
2399
2649
  const items = [];
2400
2650
  for (const m of html.matchAll(/<a class="a-link"[^>]*>([^<]+)<\/a><br>\((Version[^)]*)\)<br>([^<]*)/g)) {
2401
2651
  const period = m[3].trim();
@@ -2434,7 +2684,7 @@ export function createEngine(env) {
2434
2684
  }
2435
2685
 
2436
2686
  // parseGame8Events 的宽松回退:容忍 <a> 属性顺序/空白变化。仅在主解析未命中时使用。
2437
- function parseGame8EventsLoose(html) {
2687
+ function parseGame8EventsLoose(html, tz) {
2438
2688
  const items = [];
2439
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)) {
2440
2690
  const period = m[2].trim();
@@ -2463,9 +2713,9 @@ export function createEngine(env) {
2463
2713
  }
2464
2714
 
2465
2715
  // 终末地(Game8 经 host 代理,无 CORS):活动排期页
2466
- async function fetchGame8Endfield(pageUrl) {
2716
+ async function fetchGame8Endfield(pageUrl, _signal, tz) {
2467
2717
  const html = await proxyFetchText(pageUrl, "https://game8.co/");
2468
- return parseGame8Events(html) || parseGame8EventsLoose(html);
2718
+ return parseGame8Events(html, tz) || parseGame8EventsLoose(html, tz);
2469
2719
  }
2470
2720
 
2471
2721
  // 从 fz.wiki 页面(Next.js App Router)内联 RSC flight payload 中抽取 contentJson 的 JSON 字符串。
@@ -2500,7 +2750,7 @@ export function createEngine(env) {
2500
2750
  // 只收"有明确起止"的活动(timeRanges 末段的 open+close 都非空),
2501
2751
  // 与旧行为一致——无 close 的是新手/每周/引导等常驻活动,不当作当期活动。
2502
2752
  // 时间格式 "2026/9/2 7:00:00";外显=排序第一条(结束最早的),悬停按同序逐行。
2503
- function parseFzWikiActivities(html, now) {
2753
+ function parseFzWikiActivities(html, now, tz) {
2504
2754
  const obj = extractFzContentJson(html);
2505
2755
  if (!obj) return null;
2506
2756
  const acts = [];
@@ -2534,7 +2784,14 @@ export function createEngine(env) {
2534
2784
  // 一条活动都没解析出来 → 页面结构变了(不是"当期没活动"):抛错让该侧记 down,
2535
2785
  // 面板会显示"活动失败",而不是伪装成"新活动未公布"(历史教训:源站改版长期静默失灵)。
2536
2786
  if (acts.length === 0) throw new Error("fz-wiki-no-activities");
2537
- 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
+ };
2538
2795
  const t0 = now || nowMs();
2539
2796
  // 覆盖当前时刻的活动统一排序(③ 结束时间升序)供悬停;外显另按类别优先挑选
2540
2797
  const activeActs = sortEventItems(acts
@@ -2543,7 +2800,7 @@ export function createEngine(env) {
2543
2800
  // 解析到活动、但当期没有覆盖当前时刻的 → 返回 null(这一侧记 nomatch = "新活动未公布")
2544
2801
  if (activeActs.length === 0) return null;
2545
2802
  const primary = pickEventPrimary(activeActs) || activeActs[0]; // 叙事活动/挑战活动优先于签到类
2546
- const win = fmtWindow(primary.startTs, primary.endTs);
2803
+ const win = fmtWindow(primary.startTs, primary.endTs, tz);
2547
2804
  return {
2548
2805
  banner: primary.name,
2549
2806
  bannerDatesRaw: win,
@@ -2555,15 +2812,15 @@ export function createEngine(env) {
2555
2812
  // 终末地(FZ Wiki 经 host 代理,无 CORS):活动排期页。
2556
2813
  // 三态口径:有当期活动 → 数据;解析到活动但没有当期 → null(nomatch);
2557
2814
  // 页面结构变了/一条都解析不出 → parseFzWikiActivities 抛错(down)。
2558
- async function fetchFzWikiEndfield(pageUrl, now = nowMs()) {
2815
+ async function fetchFzWikiEndfield(pageUrl, _signal, tz, now = nowMs()) {
2559
2816
  const html = await proxyFetchText(pageUrl, "https://fz.wiki/");
2560
- return parseFzWikiActivities(html, now);
2817
+ return parseFzWikiActivities(html, now, tz);
2561
2818
  }
2562
2819
 
2563
2820
  // 通用活动源解析(自定义条目/自定义活动来源地址用):抓取页面 → parseGenericEvents → {event, eventDates}
2564
- async function tryParseGenericEvent(url, signal) {
2821
+ async function tryParseGenericEvent(url, signal, tz) {
2565
2822
  const html = await fetchHtmlText(url, signal);
2566
- const cur = parseGenericEvents(html);
2823
+ const cur = parseGenericEvents(html, tz);
2567
2824
  if (cur && cur.banner) return {
2568
2825
  event: cur.banner,
2569
2826
  eventDates: cur.bannerDates || "",
@@ -2576,19 +2833,20 @@ export function createEngine(env) {
2576
2833
  // 改用 api.php?action=ask 直接查询「分类:活动」的开始/结束时间,选当期。
2577
2834
  // 属性:名称/开始时间/结束时间/所属版本;结束时间 9999/01/01 为永久活动占位(跳过)。
2578
2835
  // 查询 URL 由 fetchYsActivity 构造,浏览器直连(api.php 带 origin=* 有 CORS)。
2579
- function parseSmwActivity(json) {
2836
+ function parseSmwActivity(json, tz) {
2580
2837
  const results = json?.query?.results || {};
2581
2838
  const now = nowMs();
2582
2839
  const covering = [];
2583
- // SMW timestamp 是 UTC 秒,raw 形如 "1/2026/8/28/10/0/0/0"(服务器本地时间 +08)。
2584
- // 用 raw 直接构造本地时间,避免 UTC 秒被本地时区再偏移。
2840
+ // SMW timestamp 是 UTC 秒,raw 形如 "1/2026/8/28/10/0/0/0"(**服务器本地时间 +08**)。
2841
+ // 用 raw 的值直接构造,但必须按**源站时区**(`tz`)解释 —— 否则海外用户
2842
+ // 得到的绝对时刻会整体偏移("是否在开/倒计时"随之出错)。
2585
2843
  const parseRaw = (v) => {
2586
2844
  if (!v) return null;
2587
2845
  if (v.raw != null) {
2588
2846
  const p = String(v.raw).split("/");
2589
2847
  if (p.length >= 8) {
2590
2848
  const y = Number(p[1]), mo = Number(p[2]), d = Number(p[3]), h = Number(p[4]), mi = Number(p[5]);
2591
- 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);
2592
2850
  }
2593
2851
  }
2594
2852
  // 回退:value 若是严格 ISO 本地时间则用之(避免把展示文本误判为时间)
@@ -2623,7 +2881,7 @@ export function createEngine(env) {
2623
2881
  // 悬停按结束时间升序;外显按类别优先(剧情活动/挑战类优先于常规/网页类)
2624
2882
  const ordered = sortEventItems(covering);
2625
2883
  const best = pickEventPrimary(ordered) || ordered[0];
2626
- const win = fmtWindow(best.startTs, best.endTs);
2884
+ const win = fmtWindow(best.startTs, best.endTs, tz);
2627
2885
  return {
2628
2886
  banner: best.name,
2629
2887
  roles: "",
@@ -2635,14 +2893,14 @@ export function createEngine(env) {
2635
2893
 
2636
2894
  // 原神 SMW 活动查询(经 origin=* 直连)
2637
2895
  // 返回 EVENT_FETCHERS 契约格式 {event, eventDates, eventDatesRaw}(parseSmwActivity 产出卡池格式,这里转换)
2638
- async function fetchYsActivity(signal) {
2896
+ async function fetchYsActivity(signal, tz) {
2639
2897
  const nowYear = new Date(nowMs()).getFullYear(); // 走注入时钟(core 不得直接读宿主时钟)
2640
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";
2641
2899
  const apiUrl = "https://wiki.biligame.com/ys/api.php?action=ask&query=" + encodeURIComponent(q) + "&format=json&origin=*";
2642
2900
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
2643
2901
  if (!res.ok) throw new Error("http-" + res.status);
2644
2902
  const json = await res.json();
2645
- const d = parseSmwActivity(json);
2903
+ const d = parseSmwActivity(json, tz);
2646
2904
  if (!d) return null;
2647
2905
  return {
2648
2906
  event: d.banner,
@@ -2657,24 +2915,26 @@ export function createEngine(env) {
2657
2915
  // mkRaw:直接抓取原始 HTML(非 MediaWiki 源,如 GachaTracker)
2658
2916
  // 经 host 同源代理的来源(蔚蓝系列 / 终末地 wiki.gg / 1999 等)不用工厂包装:
2659
2917
  // 其抓取器内部自行调用 proxyFetchText / proxyFetchJson(绕过 CORS 与 Referer 反爬)。
2660
- // 抓取器签名:async (url, signal) → 数据对象 | null
2918
+ // 抓取器签名:async (url, signal, tz) → 数据对象 | null
2919
+ // 第三个参数 `tz` = 源站墙钟时区(可选,见 15-env.js)。工厂把它透传给解析函数,
2920
+ // 解析函数再交给 parseTime/parseRange —— 这样"源站时区"只需在来源声明里写一次。
2661
2921
  function mkMediaWiki(parse) {
2662
- return async (url, signal) => {
2922
+ return async (url, signal, tz) => {
2663
2923
  const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
2664
2924
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
2665
2925
  if (!res.ok) throw new Error("http-" + res.status);
2666
2926
  const json = await res.json();
2667
2927
  const text = json?.parse?.text;
2668
2928
  if (typeof text !== "string") throw new Error("bad-json");
2669
- return parse(text);
2929
+ return parse(text, tz);
2670
2930
  };
2671
2931
  }
2672
2932
  function mkRaw(parse) {
2673
- return async (url, signal) => {
2933
+ return async (url, signal, tz) => {
2674
2934
  const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
2675
2935
  const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
2676
2936
  if (!res.ok) throw new Error("http-" + res.status);
2677
- return parse(await res.text());
2937
+ return parse(await res.text(), tz);
2678
2938
  };
2679
2939
  }
2680
2940
  // bwiki 通用"选当期"包装(原神/星铁/绝区零/方舟)
@@ -2755,7 +3015,7 @@ export function createEngine(env) {
2755
3015
 
2756
3016
  // 蔚蓝档案·国际服:nexon 官方「更新日誌」board(3352) → 当期卡池 + 当期活动
2757
3017
  // 一次请求拿到更新日誌正文,从日程表同时提取 特選招募(卡池)与 活動劇情/總力戰(活动)
2758
- async function fetchBaGlobal(logListUrl, signal, now = nowMs()) {
3018
+ async function fetchBaGlobal(logListUrl, signal, tz, now = nowMs()) {
2759
3019
  const ref = "https://forum.nexon.com/bluearchiveTW/";
2760
3020
  const list = await proxyFetchJson(logListUrl, ref);
2761
3021
  const threads = Array.isArray(list?.threads) ? list.threads : [];
@@ -2890,7 +3150,7 @@ export function createEngine(env) {
2890
3150
  // ▼実施期間 2026年9月9日(水) メンテナンス後 ~ 2026年9月23日(水・祝) 10:59
2891
3151
  // 时间按 JST(+09:00) 解析(展示时转本地);起点写「メンテナンス後」时取同期维护公告
2892
3152
  // 「▼実施時間 … ~ … 17:00前後」的结束时刻。活动取同期「イベント」条目的開催期間。
2893
- function parseBaJpNews(json, now = nowMs()) {
3153
+ function parseBaJpNews(json, now = nowMs(), tz) {
2894
3154
  const rows = Array.isArray(json?.data?.rows) ? json.data.rows : [];
2895
3155
  const clean = (s) => String(s || "")
2896
3156
  .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
@@ -2936,7 +3196,7 @@ export function createEngine(env) {
2936
3196
  // 展示用的档期必须取"被选中的那个池"自己的窗口(cur[0])—— 旧实现固定取 windows[0],
2937
3197
  // 一旦当期命中的不是第一个池,就会显示"B 池名字 + A 池时间",倒计时按错档期跑。
2938
3198
  const win0 = cur[0];
2939
- 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 || "");
2940
3200
  const data = {
2941
3201
  banner: cur[0].name,
2942
3202
  roles: [...new Set(cur.map((p) => p.student))].join("、"),
@@ -2976,10 +3236,10 @@ export function createEngine(env) {
2976
3236
 
2977
3237
  // 日服卡池默认抓取器:官网新闻接口优先;失败或无当期募集 → 回退 GameKee 当期卡池(仅池名+档期)。
2978
3238
  // 只返回卡池字段:活动字段由活动源单独负责(卡池/活动解耦,避免活动源失败时静默混入官方活动)
2979
- async function fetchBaJpGacha(url, signal, now = nowMs()) {
3239
+ async function fetchBaJpGacha(url, signal, tz, now = nowMs()) {
2980
3240
  try {
2981
3241
  const json = await proxyFetchJson(url, "https://bluearchive.jp/");
2982
- const d = parseBaJpNews(json, now);
3242
+ const d = parseBaJpNews(json, now, tz);
2983
3243
  if (d) {
2984
3244
  delete d.event;
2985
3245
  delete d.eventDates;
@@ -2991,9 +3251,9 @@ export function createEngine(env) {
2991
3251
  }
2992
3252
 
2993
3253
  // 日服活动备选抓取器:同一个官方接口的「イベント」条目(只取 event 字段)
2994
- async function fetchBaJpOfficialEvent(url, signal, now = nowMs()) {
3254
+ async function fetchBaJpOfficialEvent(url, signal, tz, now = nowMs()) {
2995
3255
  const json = await proxyFetchJson(url, "https://bluearchive.jp/");
2996
- const d = parseBaJpNews(json, now);
3256
+ const d = parseBaJpNews(json, now, tz);
2997
3257
  if (!d || !d.event) return null;
2998
3258
  return { event: d.event, eventDates: d.eventDates || "", eventDatesRaw: d.eventDatesRaw || d.eventDates || "" };
2999
3259
  }
@@ -3094,7 +3354,7 @@ export function createEngine(env) {
3094
3354
 
3095
3355
  // 蔚蓝国服(官网 bluearchive-cn.com):news/list → 最新维护更新说明 → 当期卡池/活动名 + 维护起止
3096
3356
  // 时间:维护日 14:00 ~ 下次维护前(约 +14 天,取维护日开始、预加载下一期预告前)
3097
- async function fetchBaCn(listUrl, now = nowMs()) {
3357
+ async function fetchBaCn(listUrl, _signal, tz, now = nowMs()) {
3098
3358
  const H = { "game-alias": "ba" };
3099
3359
  const ref = "https://bluearchive-cn.com/";
3100
3360
  const nowYear = new Date(now).getFullYear();
@@ -3140,7 +3400,7 @@ export function createEngine(env) {
3140
3400
  .replace(/&ldquo;/g, "「").replace(/&rdquo;/g, "」")
3141
3401
  .replace(/&hellip;/g, "…").replace(/&times;/g, "×").replace(/&bull;/g, "·")
3142
3402
  .replace(/\n\s*\n+/g, "\n").trim();
3143
- return parseBaCnMaintText(text, nowYear);
3403
+ return parseBaCnMaintText(text, nowYear, tz);
3144
3404
  }
3145
3405
  // 国服公告里「招募」开头的东西**不是一类**。实测(09月24日维护更新说明):
3146
3406
  // 更新限时限定招募【秩序隐匿于粼粼波光中】 ← 限定角色卡池(当期主卡池)
@@ -3168,7 +3428,7 @@ export function createEngine(env) {
3168
3428
  // 抽出来是为了能用合成文本精确回归 —— 这个源的坑全在措辞(见上面类型表),
3169
3429
  // 而措辞能不能认出来只能靠测解析,靠抓线上只能"等它坏了才发现"。
3170
3430
  // 无角色卡池时返回 null(调用方据此判"未公布")。
3171
- function parseBaCnMaintText(text, nowYear) {
3431
+ function parseBaCnMaintText(text, nowYear, tz) {
3172
3432
  // 卡池识别分两步:① 挑出**角色卡池**(排除招募活动);② 按档位选外显那一类。
3173
3433
  //
3174
3434
  // WHY 不能只认一种写法:措辞漂移过。旧写法 `更新限时招募【X】` 曾一度**一处都没有**
@@ -3516,20 +3776,20 @@ export function createEngine(env) {
3516
3776
 
3517
3777
  // 重返未来:1999 入口:默认源=官方游戏内公告(逐期征集时间);
3518
3778
  // 来源被切到官网公告(备选源)或自定义地址时直接走官网解析(旧行为)。
3519
- async function fetchR99(url, signal, now = nowMs()) {
3779
+ async function fetchR99(url, signal, tz, now = nowMs()) {
3520
3780
  if (!/noticecp/.test(String(url || ""))) return fetchR99Official(url, signal, now);
3521
3781
  try {
3522
3782
  return await fetchR99Notice(now);
3523
3783
  } catch {
3524
3784
  // 游戏内公告接口不可用/结构变了 → 回退官网公告(宁可少时间信息,也不要整格报错)
3525
- return fetchR99Official(R1999_OFFICIAL_URL, signal, now);
3785
+ return fetchR99Official(R1999_OFFICIAL_URL, signal, tz, now);
3526
3786
  }
3527
3787
  }
3528
3788
 
3529
3789
  // 重返未来:1999(官网 re.bluepoch.com 新闻 API,POST 经 host 代理)
3530
3790
  // 列表接口(informationType=2 资讯)按上线时间倒序返回含全文的公告,
3531
3791
  // 取最新一期「版本更新维护公告」:当期卡池(首位6星角色名,官网无征集名)/ 当期活动 / 维护起止 + 下一期维护日
3532
- async function fetchR99Official(listUrl, signal, now = nowMs()) {
3792
+ async function fetchR99Official(listUrl, signal, tz, now = nowMs()) {
3533
3793
  const ref = "https://re.bluepoch.com/";
3534
3794
  const list = await proxyFetchJson(listUrl, ref, {}, { current: 1, pageSize: 30, informationType: 2 });
3535
3795
  const items = list?.data?.pageData || [];
@@ -3649,34 +3909,38 @@ export function createEngine(env) {
3649
3909
  const GACHA_FETCHERS = {
3650
3910
  genshin: mkMediaWiki(bwikiGachaPayload),
3651
3911
  hsr: mkMediaWiki(bwikiGachaPayload),
3652
- zzz: (url, signal) => fetchZzzGacha(url, signal),
3912
+ zzz: (url, signal, tz) => fetchZzzGacha(url, signal, tz),
3653
3913
  "zzz-bwiki": mkMediaWiki(pickCurrent(parseAllBwiki)),
3654
- arknights: (url, signal) => fetchArknightsGacha(url, signal),
3655
- "arknights-prts": mkMediaWiki(selectArknights),
3656
- wuwa: (url, signal) => fetchWuwaGacha(url, signal),
3657
- "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),
3658
3922
  // 终末地默认:Canmoe(中文,Next.js 数据经 host 代理两步抓取)
3659
- endfield: (url, signal) => fetchCanmoeEndfield(url),
3923
+ endfield: (url, signal, tz) => fetchCanmoeEndfield(url, signal, tz),
3660
3924
  // 终末地备选:GachaTracker(英文,浏览器直连)/ wiki.gg(英文,经 host 代理)
3661
3925
  "endfield-gachatracker": mkRaw(parseGachaTracker),
3662
- "endfield-wiki-gg": (url, signal) => fetchEndfieldWikiGg(url),
3926
+ "endfield-wiki-gg": (url, signal, tz) => fetchEndfieldWikiGg(url, signal, tz),
3663
3927
  // 异环:ldshop(繁体,静态表格经 host 代理)
3664
- nte: (url, signal) => fetchNteWanmei(url, signal),
3665
- "nte-ldshop": (url, signal) => fetchLdshopNte(url),
3666
- "ba-cn": (url, signal) => fetchBaCn(url),
3667
- "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),
3668
3932
  "ba-global-gamekee": () => fetchGameKeeBa("global"),
3669
- "ba-jp": (url, signal) => fetchBaJpGacha(url, signal),
3933
+ "ba-jp": (url, signal, tz) => fetchBaJpGacha(url, signal, tz),
3670
3934
  "ba-jp-gamekee": () => fetchGameKeeBa("jp"),
3671
- "r1999": (url, signal) => fetchR99(url, signal),
3935
+ "r1999": (url, signal, tz) => fetchR99(url, signal, tz),
3672
3936
  // 重返未来1999 备选:官网公告(只有维护时间与活动名,无逐期征集时间)
3673
- "r1999-official": (url, signal) => fetchR99Official(url, signal)
3937
+ "r1999-official": (url, signal, tz) => fetchR99Official(url, signal, tz)
3674
3938
  };
3675
3939
  // 活动源注册表:条目 → { 默认 + 备选抓取器 }。没有独立活动源的条目活动来源显示"未配置"。
3676
3940
  const EVENT_FETCHERS = {
3677
3941
  // 原神:活动一览为 JS 动态加载(Dquery+SMW),走 SMW ask 查询(fetchYsActivity 忽略 URL 参数)
3678
3942
  genshin: {
3679
- default: (url, signal) => fetchYsActivity(signal)
3943
+ default: (url, signal, tz) => fetchYsActivity(signal, tz)
3680
3944
  },
3681
3945
  // 星铁:活动一览(静态「活动时间」表,api.php 可直连)→ 外显当期 + 悬停列出全部并行活动
3682
3946
  hsr: {
@@ -3685,14 +3949,14 @@ export function createEngine(env) {
3685
3949
  // 绝区零:默认=官方公告(api-takumi-static,与卡池侧同一接口;活动时间写在公告正文里,
3686
3950
  // 含"X.Y版本更新后/版本结束"的换算;结束时间未知的活动保留并沉底)→ 备选=Bwiki 活动一览
3687
3951
  zzz: {
3688
- default: (url, signal) => fetchZzzEventsOfficial(url, signal),
3952
+ default: (url, signal, tz) => fetchZzzEventsOfficial(url, signal, tz),
3689
3953
  "zzz-event-bwiki": mkMediaWiki(genericEventPayload)
3690
3954
  },
3691
- // 异环:活动源就是同一篇官网公告(与卡池侧同址,fetchNteWanmei 一个函数同时解析两者)。
3955
+ // 异环:活动源就是同一篇官网公告(与卡池侧**同一条 URL**,fetchNteWanmei 一个函数同时解析两者)。
3692
3956
  // 注册成独立活动源的意义:卡池侧本轮抓挂、或用户把**卡池**来源改成自定义/备选时,
3693
- // 活动侧仍能自己抓、自己报错,而不是整列空掉(同址复用只是"能省一次请求"的优化,不是它的腿)。
3957
+ // 活动侧仍能自己抓、自己报错,而不是整列空掉(复用只是"同一 URL 省一次请求"的优化,不是它的腿)。
3694
3958
  nte: {
3695
- default: (url, signal) => fetchNteWanmei(url, signal)
3959
+ default: (url, signal, tz) => fetchNteWanmei(url, signal, tz)
3696
3960
  },
3697
3961
  // 明日方舟:PRTS 活动一览(「活动开始时间」表 + data-time 起止时间戳)→ 同上
3698
3962
  arknights: {
@@ -3700,40 +3964,44 @@ export function createEngine(env) {
3700
3964
  },
3701
3965
  // 终末地:FZ Wiki(中文,经 host 代理、抓 RSC 数据;外显当期=结束最晚,悬停列出全部并行)
3702
3966
  endfield: {
3703
- default: (url, signal) => fetchFzWikiEndfield(url).then((d) =>
3967
+ default: (url, signal, tz) => fetchFzWikiEndfield(url, signal, tz).then((d) =>
3704
3968
  d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null
3705
3969
  ),
3706
- "endfield-game8": (url, signal) => fetchGame8Endfield(url).then((d) =>
3970
+ "endfield-game8": (url, signal, tz) => fetchGame8Endfield(url, signal, tz).then((d) =>
3707
3971
  d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "" } : null
3708
3972
  )
3709
3973
  },
3710
- // 鸣潮:活动日历页(独立 eventUrl 源)→ 外显当期 + 悬停列出全部并行活动
3974
+ // 鸣潮:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动),与卡池是**同一条 URL**
3975
+ // → 一次请求复用两侧数据(refresh 的复用优化)。
3976
+ // ⚠️ 2026-10-01 改:Bwiki 活动日历**已停更**(最新一条结束于 2026/9/29),
3977
+ // 所以把官方提为默认、Bwiki 降级为备选(`wuwa-event-bwiki`),仍可在设置里手动切回。
3711
3978
  wuwa: {
3712
- default: mkMediaWiki((html) => {
3713
- const d = parseWuwaCalendar(html);
3979
+ default: (url, signal, tz) => fetchWuwaEventsOfficial(url, signal, tz),
3980
+ "wuwa-event-bwiki": mkMediaWiki((html) => {
3981
+ const d = parseWuwaCalendar(html, tz);
3714
3982
  return d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null;
3715
3983
  })
3716
3984
  },
3717
3985
  // 蔚蓝国服:默认与卡池同 URL(维护公告含活动名),也可独立配置其他来源
3718
3986
  "ba-cn": {
3719
- default: (url, signal) => fetchBaCn(url)
3987
+ default: (url, signal, tz) => fetchBaCn(url, signal, tz)
3720
3988
  },
3721
3989
  // 蔚蓝国际服:默认与卡池同 URL(更新日誌含活动排期);备选 GameKee
3722
3990
  "ba-global": {
3723
- default: (url, signal) => fetchBaGlobal(url, signal),
3991
+ default: (url, signal, tz) => fetchBaGlobal(url, signal, tz),
3724
3992
  "ba-global-gamekee": () => fetchGameKeeBa("global")
3725
3993
  },
3726
3994
  // 蔚蓝日服:活动源与卡池源独立——默认 GameKee 当期活动条目(原行为不变);
3727
3995
  // 备选 = 日服官方公告里的イベント条目(抓取器复用官方解析,仅取 event/eventDates)
3728
3996
  "ba-jp": {
3729
3997
  default: () => fetchGameKeeBa("jp"),
3730
- "ba-jp-official": (url, signal) => fetchBaJpOfficialEvent(url, signal)
3998
+ "ba-jp-official": (url, signal, tz) => fetchBaJpOfficialEvent(url, signal, tz)
3731
3999
  },
3732
4000
  // 重返未来:默认与卡池同 URL(同一篇「版本活动一览」同时含征集与活动);
3733
4001
  // 备选=官网公告(只有维护时间与活动名)
3734
4002
  "r1999": {
3735
- default: (url, signal) => fetchR99(url, signal),
3736
- "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)
3737
4005
  }
3738
4006
  };
3739
4007
 
@@ -3772,16 +4040,17 @@ export function createEngine(env) {
3772
4040
  // 才允许通用解析兜底——内置默认源不兜底:代理源直连必被 CORS 拦,那个错误不该算到来源头上。
3773
4041
  // 返回 { data, fail }:fail=null 表示该侧成功,否则 { kind: "down"|"nomatch", reason }。
3774
4042
  // data 一律原样带回(未命中时也可能附带可供同 URL 复用的其它字段)。
3775
- async function resolveSide(side, { fetcher, url, allowGeneric, signal }) {
4043
+ // `tz`(可选)= 源站墙钟时区,透传给抓取器/解析器(不传 = 本机时区,向后兼容)。
4044
+ async function resolveSide(side, { fetcher, url, allowGeneric, signal, tz }) {
3776
4045
  const emptyOf = (d) => (side === "gacha" ? !d || !d.banner : !d || !d.event);
3777
4046
  const parseGeneric = () => (side === "gacha"
3778
- ? tryParseGenericGacha(url, signal)
3779
- : tryParseGenericEvent(url, signal));
4047
+ ? tryParseGenericGacha(url, signal, tz)
4048
+ : tryParseGenericEvent(url, signal, tz));
3780
4049
  let data = null;
3781
4050
  let fail = null;
3782
4051
  if (fetcher) {
3783
4052
  try {
3784
- data = await fetcher(url, signal);
4053
+ data = await fetcher(url, signal, tz);
3785
4054
  } catch (err) {
3786
4055
  if (err && err.name === "AbortError") throw err;
3787
4056
  fail = { kind: "down", reason: normErr(err) };
@@ -3819,15 +4088,31 @@ export function createEngine(env) {
3819
4088
  fetcher: gachaFetcherFor(source, gachaUrl),
3820
4089
  url: gachaUrl,
3821
4090
  allowGeneric: !!(source.custom || source.allowGenericGacha),
3822
- signal
4091
+ signal,
4092
+ tz: source.tz
3823
4093
  });
3824
4094
  }
3825
4095
  let evData = null;
3826
4096
  let eventFail = null;
3827
4097
  if (source.eventUrl) {
3828
- // 活动侧自己的抓取器(与卡池侧各自独立选择,来源可以完全不同)
4098
+ // 活动侧自己的抓取器(与卡池侧各自独立选择,来源可以完全不同)。
4099
+ // 注意:**默认路径就是"活动侧自己抓"** —— 解耦是常态,复用只是优化。
3829
4100
  const evFetcher = eventFetcherFor(source, eventUrl);
3830
- if (eventUrl === gachaUrl && g.data && g.data.event) {
4101
+ // ── 两侧同一条 URL 时的请求复用:**命中才触发**的分支 ───────────
4102
+ // 只有两个条件**同时**成立才复用,否则一律走下面的独立抓取:
4103
+ // ① 两侧是**同一条 URL**(按**完整 URL 含查询参数**比,不是「同一个网站」)
4104
+ // —— 例如星铁的卡池与活动都是 wiki.biligame.com/sr/api.php,
4105
+ // 但 `page=历史跃迁` vs `page=活动一览`,**不是**同一条 URL;
4106
+ // 而绝区零/鸣潮两侧是字面完全相同的 URL,一次请求的响应完全一样。
4107
+ // ② 卡池那次的载荷**确实带了活动字段**(`event`)
4108
+ // 复用只是"省掉一次**同一 URL** 的重复请求",**不是解耦的腿**:
4109
+ // 活动侧的抓取器、失败归因、来源选择都保持独立(见 §53)。
4110
+ //
4111
+ // 2026-10-01 实测(11 内置条目逐条):复用支命中 5 次
4112
+ // (wuwa / nte / r1999 / ba-cn / ba-global —— 它们的卡池载荷本身就含活动字段),
4113
+ // 其余 6 条目(zzz / genshin / hsr / arknights / ba-jp / endfield)走独立抓取。
4114
+ const reuseSameRequest = eventUrl === gachaUrl && !!(g.data && g.data.event);
4115
+ if (reuseSameRequest) {
3831
4116
  // 同 URL:活动字段就在卡池载荷里,不重复抓
3832
4117
  evData = {
3833
4118
  event: g.data.event,
@@ -3836,17 +4121,26 @@ export function createEngine(env) {
3836
4121
  eventHover: g.data.eventHover || ""
3837
4122
  };
3838
4123
  } else if (eventUrl === gachaUrl && !evFetcher) {
3839
- // 同 URL 且活动侧**没有自己的抓取器**(该条目就只有这一份载荷可用):
3840
- // · 卡池那次已抓成功但载荷里没有活动字段 → 该侧就是"未公布";
3841
- // · 卡池那次本身失败 → 沿用它的失败原因(同一次请求的结果,不该另起一个"无可用来源")。
3842
- // 只在这一种情况下短路:若活动侧有自己的抓取器(如绝区零/异环),照旧独立抓取,解耦不变。
4124
+ // ── 两侧同一条 URL、但活动侧**没有自己的抓取器**(该条目就只有这一份载荷可用)──
4125
+ // ⚠️ **这一支不是死代码,别删**:**自定义条目**可以没有注册抓取器 ——
4126
+ // `getAllEntries` 里 customEntries 带 `custom:true` + 用户填的 url/eventUrl,
4127
+ // 而 GACHA_FETCHERS / EVENT_FETCHERS 里没有它们的 id → `eventFetcherFor` 返回 null。
4128
+ // 语义:
4129
+ // · 卡池那次成功但载荷没有活动字段 → 该侧就是"未公布"(nomatch),
4130
+ // 且**不发**那次注定被 CORS 拦的直连;
4131
+ // · 卡池那次本身失败 → 沿用**同一次请求**的失败原因(如 HTTP 567),
4132
+ // 而不是笼统报"无可用来源"。
4133
+ // 教训:我一度只按"内置 11 条目命中 0 次"就判它死代码 —— **错的**,
4134
+ // 把"当前没命中"当成了"不可达"。`_batch1` A3 用合成条目(sim-nte)刻意覆盖这两条语义。
3843
4135
  eventFail = g.data ? { kind: "nomatch" } : (g.fail || { kind: "nomatch" });
3844
4136
  } else {
4137
+ // 活动侧独立抓取(含"两侧不是同一条 URL"与"同一条 URL 但载荷无活动字段"两种情形)
3845
4138
  const ev = await resolveSide("event", {
3846
4139
  fetcher: evFetcher,
3847
4140
  url: eventUrl,
3848
4141
  allowGeneric: !!(source.custom || source.allowGenericEvent),
3849
- signal
4142
+ signal,
4143
+ tz: source.tz
3850
4144
  });
3851
4145
  evData = ev.data;
3852
4146
  eventFail = ev.fail;
@@ -3994,14 +4288,48 @@ export function createEngine(env) {
3994
4288
  if (refreshInFlight) return refreshInFlight;
3995
4289
  refreshInFlight = (async () => {
3996
4290
  const s = await readSettings();
3997
- // 用"全部条目"(含隐藏)抓取:隐藏再显示时立刻有数据,与既有行为一致
3998
- 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]));
3999
4304
  const result = await refreshAll(entries, s);
4000
4305
  const prev = await getCached();
4001
4306
  const games = {};
4002
- entries.forEach((e, i) => {
4003
- const rec = mergeEntryRecord(result.results[i], prev.games[e.id], nowMs());
4004
- 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;
4005
4333
  // 统一成固定形状(内容字段缺失填空串)——见 engine-head.js 的 normalizeRecord
4006
4334
  games[e.id] = normalizeRecord(rec, e.name);
4007
4335
  });
@@ -4026,11 +4354,15 @@ export function createEngine(env) {
4026
4354
  // 解析器自检(交接文档 §13.4):对每个条目的两侧来源各跑一次,报告「解析出什么 / 报错原因」。
4027
4355
  // 用途:源站改版时快速定位「哪个源解析出 0 条、哪个源 403/超时」——各平台都能调用
4028
4356
  // (将来扩展里做「自检」按钮、CLI、CI 都行)。**只读**:不写缓存、不动设置。
4357
+ //
4358
+ // 口径与刷新一致:**只自检"选中展示"的条目**(同 getVisibleEntries)。
4359
+ // 理由:自检的用途是"定位当前实际在用的源",而隐藏条目既不在面板显示、也不参与刷新;
4360
+ // 把它们一起报出来只会让结论里混进与当前无关的失败(如已隐藏的自定义条目 404)。
4029
4361
  async function selfCheck(options) {
4030
4362
  useEnv();
4031
4363
  const timeoutMs = (options && options.timeoutMs) || REFRESH_TIMEOUT_MS;
4032
4364
  const s = await readSettings();
4033
- const entries = getAllEntries(s);
4365
+ const entries = getVisibleEntries(s);
4034
4366
  const targets = entries.map((e) => ({
4035
4367
  ...e,
4036
4368
  url: getEntryUrl(s, e.id, e.url),