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