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.
- package/core.mjs +519 -187
- 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
|
+
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1215
|
-
return `${
|
|
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 =
|
|
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
|
-
//
|
|
1389
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
const
|
|
1402
|
-
const
|
|
1403
|
-
if (!
|
|
1404
|
-
const sTs =
|
|
1405
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
1420
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(/ /g, " ").replace(/&/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
|
-
|
|
1448
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
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
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
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"
|
|
2584
|
-
// 用 raw
|
|
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
|
|
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(/“/g, "「").replace(/”/g, "」")
|
|
3141
3401
|
.replace(/…/g, "…").replace(/×/g, "×").replace(/•/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
|
-
|
|
3656
|
-
|
|
3657
|
-
"
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
3713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
4003
|
-
const
|
|
4004
|
-
if (
|
|
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 =
|
|
4365
|
+
const entries = getVisibleEntries(s);
|
|
4034
4366
|
const targets = entries.map((e) => ({
|
|
4035
4367
|
...e,
|
|
4036
4368
|
url: getEntryUrl(s, e.id, e.url),
|