gacha-calendar-core 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +92 -0
  2. package/core.mjs +3225 -0
  3. package/package.json +29 -0
package/core.mjs ADDED
@@ -0,0 +1,3225 @@
1
+ //#region config
2
+ const NS = "gacha-calendar";
3
+ // 刷新频率选项:按天(存分钟),与 host 端 Config.refreshMinutes 对应
4
+ const REFRESH_OPTIONS = [
5
+ { label: "1 天", minutes: 1 * 24 * 60 },
6
+ { label: "5 天", minutes: 5 * 24 * 60 },
7
+ { label: "7 天", minutes: 7 * 24 * 60 },
8
+ { label: "15 天", minutes: 15 * 24 * 60 },
9
+ { label: "24 天", minutes: 24 * 24 * 60 },
10
+ { label: "30 天", minutes: 30 * 24 * 60 },
11
+ { label: "42 天", minutes: 42 * 24 * 60 }
12
+ ];
13
+ const DEFAULT_SETTINGS = {
14
+ autoRefresh: true,
15
+ refreshMinutes: 1 * 24 * 60,
16
+ order: null,
17
+ lastRefresh: 0,
18
+ lastSource: "none",
19
+ // 最近一次联网抓取的解析结果(JSON:{ [gameId]: {banner,bannerDates,roles,event,eventDates} })
20
+ lastData: "",
21
+ // 不展示的条目 id 列表(设置页开关)
22
+ hidden: [],
23
+ // 已删除的条目 id 列表(内置条目删除后记录,避免下次加载复活)
24
+ removed: [],
25
+ // 自定义爬取地址(JSON:{ [gameId]: "url" },覆盖内置 url;空串 = 用默认)
26
+ customUrls: "{}",
27
+ // 自定义活动来源地址(JSON:{ [gameId]: "url" },覆盖内置 eventUrl)
28
+ customEventUrls: "{}",
29
+ // 自定义条目(JSON 数组,字段与 SOURCES 一致)
30
+ customEntries: "[]"
31
+ };
32
+ //#endregion
33
+
34
+ //#region data sources
35
+ // 数据源声明:不含任何内置快照数据(无联网抓取时对应列显示为空)。
36
+ // 卡池来源(url/altSources)与活动来源(eventUrl/eventAltSources)完全独立、各自单独选择;
37
+ // 默认活动源与卡池源相同时(ba-*/r1999 的公告同时含卡池与活动),仍作为独立来源存在,
38
+ // 抓取时同 URL 单次请求复用,换用其他来源时独立抓取。
39
+ // 明日方舟来源地址:默认=官方公告 CMS(web-news.hypergryph.com);PRTS 卡池一览为备选(mediawiki,浏览器直连)
40
+ const AK_OFFICIAL_BULLETIN = "https://web-news.hypergryph.com/api/bulletin";
41
+ const ARKNIGHTS_OFFICIAL_LIST_URL = AK_OFFICIAL_BULLETIN + "?lang=zh-cn&code=arknights&page=1&pageSize=30";
42
+ 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";
43
+ // 鸣潮官方公告(aki-gm-resources-back):entrypoint.json → 目录(含 hash)→ <dir>/zh-Hans.json 全量公告,
44
+ // 其中 recommend 组含逐期「角色/武器活动唤取」公告,正文带 ✦活动时间✦ 起止
45
+ const WUWA_NOTICE_ENTRY = "https://aki-gm-resources-back.aki-game.com/gamenotice/G152/76402e5b20be2c39f095a152090afddc/entrypoint.json";
46
+ 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";
47
+ // 绝区零官网公告(api-takumi-static content_v2_user,经 host 代理;无 CORS、无需登录):
48
+ // iChanId=279(公告频道)返回逐条公告正文(sIntro/sContent),其中「X.Y版本限时频段(上/下期)」
49
+ // 含该期精确起止 + 限定 S 级代理人/音擎;起点写「版本更新后」时取同版本「更新公告」的 dtStartTime。
50
+ const ZZZ_NEWS_LIST_URL = "https://api-takumi-static.mihoyo.com/content_v2_user/app/706fd13a87294881/getContentList?iChanId=279&iPageSize=50&iPage=1&sLangKey=zh-cn";
51
+ const ZZZ_BWIKI_URL = "https://wiki.biligame.com/zzz/api.php?action=parse&page=%E5%BE%80%E6%9C%9F%E8%B0%83%E9%A2%91&prop=text&format=json&formatversion=2";
52
+ // 蔚蓝档案·日服 官网新闻接口(api-web.bluearchive.jp,经 host 代理;无 CORS、无需登录):
53
+ // 返回 {data:{rows:[{id,title(お知らせ/イベント/メンテナンス),summary,content,publishTime}],count}},
54
+ // 「ピックアップ募集紹介」条目内含逐池「ピックアップ名/ピックアップ生徒/実施期間」。
55
+ const BA_JP_NEWS_URL = "https://api-web.bluearchive.jp/api/news/list?pageIndex=1&pageNum=30";
56
+ const SOURCES = [
57
+ {
58
+ id: "genshin",
59
+ // parserVersion:该条目「解析逻辑」的版本号 —— 源站改版/规则更新后 +1。
60
+ // 用途:无服务端分发时定位「坏了的是哪个版本的用户、哪个源」(见交接文档 §13.4)。
61
+ parserVersion: 1,
62
+ name: "原神",
63
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/b/b0/%E5%8E%9F%E7%A5%9E%E5%9B%BE%E6%A0%87.png!/fw/64",
64
+ source: "Bwiki \u5F80\u671F\u7948\u613F",
65
+ url: "https://wiki.biligame.com/ys/api.php?action=parse&page=%E5%BE%80%E6%9C%9F%E7%A5%88%E6%84%BF&prop=text&format=json&formatversion=2",
66
+ // 独立活动源:原神活动一览为 JS 动态加载(Dquery+SMW),经 SMW ask 查询开始/结束时间
67
+ eventUrl: "https://wiki.biligame.com/ys/api.php?action=ask&query=%5B%5B%E5%88%86%E7%B1%BB%3A%E6%B4%BB%E5%8A%A8%5D%5D&format=json",
68
+ eventSource: "Bwiki \u6D3B\u52A8\u4E00\u89C8"
69
+ },
70
+ {
71
+ id: "hsr",
72
+ parserVersion: 1,
73
+ name: "崩坏:星穹铁道",
74
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/3/38/HonkaiStarRailIcon_StartingVer3.6_CHN.png!/fw/64",
75
+ source: "Bwiki \u5386\u53F2\u8DC3\u8FC1",
76
+ url: "https://wiki.biligame.com/sr/api.php?action=parse&page=%E5%8E%86%E5%8F%B2%E8%B7%83%E8%BF%81&prop=text&format=json&formatversion=2",
77
+ // 独立活动源:星铁活动一览(api.php 带 origin=* 可浏览器直连;「活动时间」表含当期活动)
78
+ eventUrl: "https://wiki.biligame.com/sr/api.php?action=parse&page=%E6%B4%BB%E5%8A%A8%E4%B8%80%E8%A7%88&prop=text&format=json&formatversion=2",
79
+ eventSource: "Bwiki \u6D3B\u52A8\u4E00\u89C8"
80
+ },
81
+ {
82
+ id: "zzz",
83
+ parserVersion: 1,
84
+ name: "绝区零",
85
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/3/3e/ZZZ_miYoYo_logo.jpg!/fw/64",
86
+ source: "官方公告",
87
+ // 默认卡池源=官网公告(api-takumi-static content_v2_user,经 host 代理,无 CORS、无需登录):
88
+ // 「X.Y版本限时频段(上/下期)」公告含该期精确起止与限定 S 级代理人/音擎;
89
+ // 无当期频段公告或抓取失败时由抓取器自动回退 Bwiki 往期调频;也可在设置中手动切 Bwiki(备选)
90
+ url: ZZZ_NEWS_LIST_URL,
91
+ altSources: [
92
+ { label: "Bwiki 往期调频", url: ZZZ_BWIKI_URL, fetcher: "zzz-bwiki" }
93
+ ],
94
+ // 独立活动源:绝区零活动一览(api.php 带 origin=* 可直连;「活动时间」表含当期活动)
95
+ eventUrl: "https://wiki.biligame.com/zzz/api.php?action=parse&page=%E6%B4%BB%E5%8A%A8%E4%B8%80%E8%A7%88&prop=text&format=json&formatversion=2",
96
+ eventSource: "Bwiki \u6D3B\u52A8\u4E00\u89C8"
97
+ },
98
+ {
99
+ id: "wuwa",
100
+ parserVersion: 1,
101
+ name: "鸣潮",
102
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/2/29/WutheringWavesIcon.png!/fw/64",
103
+ source: "官方公告",
104
+ // 默认卡池源=官方公告(aki-gm-resources-back,经 host 代理):逐期「角色/武器活动唤取」公告含起止时间;
105
+ // 无当期公告或抓取失败时由抓取器自动回退 Bwiki 角色轮换池;也可在设置中手动切 Bwiki(备选)
106
+ url: WUWA_NOTICE_ENTRY,
107
+ altSources: [
108
+ { label: "Bwiki 角色轮换池", url: WUWA_BWIKI_URL, fetcher: "wuwa-bwiki" }
109
+ ],
110
+ // 独立活动源:鸣潮活动日历页(与卡池源不同)
111
+ eventUrl: "https://wiki.biligame.com/wutheringwaves/api.php?action=parse&page=%E9%A6%96%E9%A1%B5%2F%E6%B4%BB%E5%8A%A8%E6%97%A5%E5%8E%86&prop=text&format=json&formatversion=2",
112
+ eventSource: "Bwiki 活动日历"
113
+ },
114
+ {
115
+ id: "arknights",
116
+ parserVersion: 1,
117
+ name: "明日方舟",
118
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/4/41/ArknightsAppIcon.png!/fw/64",
119
+ source: "官方公告+PRTS",
120
+ // 默认卡池源=官方公告 CMS(经 host 代理):官方只对限时/联动类寻访发公告,
121
+ // 无当期寻访公告(常规轮换周)时由抓取器自动回退 PRTS;也可在设置中手动切 PRTS 卡池一览(备选)
122
+ url: ARKNIGHTS_OFFICIAL_LIST_URL,
123
+ altSources: [
124
+ { label: "PRTS 卡池一览", url: ARKNIGHTS_PRTS_URL, fetcher: "arknights-prts" }
125
+ ],
126
+ // 独立活动源:PRTS 活动一览(「活动开始时间」表 + data-time 起止时间戳)
127
+ eventUrl: "https://prts.wiki/api.php?action=parse&page=%E6%B4%BB%E5%8A%A8%E4%B8%80%E8%A7%88&prop=text&format=json&formatversion=2",
128
+ eventSource: "PRTS \u6D3B\u52A8\u4E00\u89C8"
129
+ },
130
+ {
131
+ id: "endfield",
132
+ parserVersion: 1,
133
+ name: "明日方舟:终末地",
134
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/f/f1/ArknightsEndfieldAppIcon.png!/fw/64",
135
+ source: "Canmoe",
136
+ // 经 host 代理抓取(canmoe 无 CORS 头,浏览器直连会被拦截);
137
+ // 数据在 Next.js 组件 chunk 的 JS 里,需先抓页面定位 chunk 再抓 chunk 解析
138
+ url: "https://end.canmoe.com/zh-CN/banner-calendar",
139
+ // 卡池备选来源(设置页下拉可切换;GachaTracker 浏览器直连,wiki.gg 经 host 代理)
140
+ altSources: [
141
+ {
142
+ label: "GachaTracker\uff08\u82F1\u6587\uff09",
143
+ url: "https://gachatracker.app/games/endfield/banners/",
144
+ fetcher: "endfield-gachatracker"
145
+ },
146
+ {
147
+ label: "wiki.gg\uff08\u82F1\u6587\uff09",
148
+ // wiki.gg 校验 Referer:抓取器内部经 host 代理(代理默认 Referer=目标 origin 满足要求)
149
+ url: "https://endfield.wiki.gg/api.php?action=parse&page=Headhunting%2FBanners&prop=text&format=json&formatversion=2",
150
+ fetcher: "endfield-wiki-gg"
151
+ }
152
+ ],
153
+ // 独立活动源:FZ Wiki(中文社区维护,经 host 代理抓 RSC 数据;当期并行活动选结束最晚)
154
+ eventUrl: "https://fz.wiki/wiki/%E6%B4%BB%E5%8A%A8",
155
+ eventSource: "FZ Wiki",
156
+ // 活动备选来源:Game8(英文,经 host 代理)
157
+ eventAltSources: [
158
+ { label: "Game8\uff08\u82F1\u6587\uff09", url: "https://game8.co/games/Arknights-Endfield/archives/535443", fetcher: "endfield-game8" }
159
+ ]
160
+ },
161
+ {
162
+ id: "ba-cn",
163
+ parserVersion: 1,
164
+ name: "蔚蓝档案·国服",
165
+ icon: "https://webcnstatic.yostar.net/ba_cn_web/prod/web/favicon.png?x-oss-process=image/resize,w_64",
166
+ source: "\u5B98\u7F51\u516C\u544A",
167
+ // 经 host 代理 POST 抓取(官网 CORS=null + 动态 SPA);维护说明同时含当期卡池与活动
168
+ url: "https://bluearchive-cn.com/api/news/list?pageIndex=1&pageNum=30&type=",
169
+ // 活动默认源与卡池源相同(同一公告解析活动名),可独立切换
170
+ eventUrl: "https://bluearchive-cn.com/api/news/list?pageIndex=1&pageNum=30&type=",
171
+ eventSource: "\u5B98\u7F51\u516C\u544A"
172
+ },
173
+ {
174
+ id: "ba-global",
175
+ parserVersion: 1,
176
+ name: "蔚蓝档案·国际服",
177
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/2/25/AppIcon_Arona.png!/fw/64",
178
+ source: "Nexon \u66F4\u65B0\u65E5\u8A8C",
179
+ // 经 host 代理抓取(nexon CORS 只允许同源);「更新日誌」board(3352) 同时含当期卡池与活动排期
180
+ url: "https://forum.nexon.com/api/v1/board/3352/threads?alias=bluearchiveTW&countryCode=KR&pageNo=1&paginationType=PAGING&pageSize=30&blockSize=5&hideType=WEB",
181
+ // 卡池备选:GameKee(中文标题含排期;默认仍是 Nexon 官方更新日誌)
182
+ altSources: [
183
+ { label: "GameKee \u5F53\u671F\u5361\u6C60", id: "ba-global:gamekee", fetcher: "ba-global-gamekee" }
184
+ ],
185
+ // 活动默认源与卡池源相同(更新日誌解析活动排期),可独立切换(如 GameKee)
186
+ eventUrl: "https://forum.nexon.com/api/v1/board/3352/threads?alias=bluearchiveTW&countryCode=KR&pageNo=1&paginationType=PAGING&pageSize=30&blockSize=5&hideType=WEB",
187
+ eventSource: "Nexon \u66F4\u65B0\u65E5\u8A8C",
188
+ // 活动备选来源
189
+ eventAltSources: [
190
+ { label: "GameKee \u5F53\u671F\u6D3B\u52A8", id: "ba-global:gamekee", fetcher: "ba-global-gamekee" }
191
+ ]
192
+ },
193
+ {
194
+ id: "ba-jp",
195
+ parserVersion: 1,
196
+ name: "蔚蓝档案·日服",
197
+ icon: "https://play-lh.googleusercontent.com/H975s6W1-boCSogzpF5_rIyawbjiXfG842ncgjIRiVGzhXHFTCVut0DkBhlDR4CgN1nn98OOC1fWN-LE7kUHnQ=s64",
198
+ source: "官方公告(日文)",
199
+ // 日服官网新闻接口(api-web.bluearchive.jp,经 host 代理):「ピックアップ募集紹介」公告
200
+ // 内含逐池「ピックアップ名/ピックアップ生徒」与「実施期間」,可直接得到日文官方池名与 UP 生徒
201
+ url: BA_JP_NEWS_URL,
202
+ // 卡池备选:GameKee 当期卡池(原标题解析,只有池名+档期、无角色名)
203
+ altSources: [
204
+ { label: "GameKee 当期卡池", id: "ba-jp:gamekee", fetcher: "ba-jp-gamekee" }
205
+ ],
206
+ // 活动源与卡池源保持独立(解耦):默认仍是原「GameKee 当期活动」条目;
207
+ // 官方公告的イベント条目作为**可选备选**(备选 value 用其接口地址,抓取器 ba-jp-official)
208
+ eventUrl: "https://www.gamekee.com/ba/huodong/15",
209
+ eventSource: "GameKee 当期活动",
210
+ eventAltSources: [
211
+ { label: "官方公告(日文)", url: BA_JP_NEWS_URL, fetcher: "ba-jp-official" }
212
+ ]
213
+ },
214
+ {
215
+ id: "r1999",
216
+ parserVersion: 1,
217
+ name: "重返未来:1999",
218
+ icon: "https://play-lh.googleusercontent.com/LwcueZMBbLq6aELtqJVn61ToKkJUgxEO8O4KgK_5052hfYoDAglQJIzqSu8srUJeaOZwv36Qi5YKtsXZjo-JPg=s64",
219
+ source: "\u5B98\u7F51\u516C\u544A+\u5C0F\u7C73",
220
+ // 经 host 代理 POST 抓取(官网新闻 API,征集名另经小米官方资讯流增强);维护公告同时含当期卡池与活动
221
+ url: "https://re.bluepoch.com/activity/official/websites/information/query",
222
+ eventUrl: "https://re.bluepoch.com/activity/official/websites/information/query",
223
+ eventSource: "\u5B98\u7F51\u516C\u544A"
224
+ },
225
+ {
226
+ id: "nte",
227
+ parserVersion: 1,
228
+ name: "异环",
229
+ icon: "https://storage.moegirl.org.cn/moegirl/commons/8/8c/YH_APP.png!/fw/64",
230
+ source: "官网公告",
231
+ // 经 host 代理抓取(wanmei 跨域无 CORS);官方公告(服务端渲染)含当期限定棋盘卡池 + 限时活动起止
232
+ url: "https://yh.wanmei.com/news/gamebroad/",
233
+ // 活动源同官网公告(同一公告同时含卡池与活动)
234
+ eventUrl: "https://yh.wanmei.com/news/gamebroad/",
235
+ eventSource: "官网公告",
236
+ // 备选:LDSHOP(静态表格,经 host 代理)
237
+ altSources: [
238
+ { label: "LDSHOP", url: "https://www.ldshop.gg/tw/blog/nte/neverness-to-everness-banner.html", fetcher: "nte-ldshop" }
239
+ ]
240
+ }
241
+ ];
242
+ //#endregion
243
+
244
+ //#region helpers(core 与外壳共用:纯函数、零宿主依赖)
245
+ // —— 抓取状态与错误归一(统一机制,不做任何游戏特判)——
246
+ // 每一侧(卡池/活动)只有三种状态:
247
+ // ok :本次抓到当期内容
248
+ // down :抓取器报错(网络错误 / CORS 被拦 / 超时 / HTTP 非 2xx / 解析崩,都算)
249
+ // nomatch :请求成功,但源站里没有当期内容("未命中",不是失败)
250
+ // 判定口径:任一侧 down → 整条 ok=false;只有 nomatch → 仍算 ok(面板提示"未公布")。
251
+ // 环境原文(fetch failed / Failed to fetch / NetworkError…)一律归一成简短中文,不再外显。
252
+ // 放在 helpers(而非 core 内部)是因为**外壳也要用**:面板要在列悬停里显示失败原因、
253
+ // 要在顶部拼"成功 N/M + 五类归类",这些都只依赖失败对象本身,与抓取无关。
254
+ const SIDE_TEXT = {
255
+ gacha: { fail: "卡池失败", nomatch: "新卡池未公布" },
256
+ event: { fail: "活动失败", nomatch: "新活动未公布" }
257
+ };
258
+ function normErr(err) {
259
+ const name = String((err && err.name) || "");
260
+ const msg = String((err && err.message) || err || "");
261
+ if (name === "AbortError" || /abort/i.test(msg)) return "超时";
262
+ if (/^proxy-bad:/.test(msg)) return "代理响应异常";
263
+ const m = msg.match(/^(?:proxy-http|http)-(\d{3})$/);
264
+ if (m) return "HTTP " + m[1];
265
+ if (/^bad-json$/.test(msg)) return "响应格式异常";
266
+ if (/fetch failed|Failed to fetch|NetworkError|net::|Load failed|network error/i.test(msg)) return "网络不通";
267
+ if (/^no-source$/.test(msg)) return "无可用来源";
268
+ return "抓取异常";
269
+ }
270
+ const isDown = (f) => !!f && f.kind === "down";
271
+ const isNomatch = (f) => !!f && f.kind === "nomatch";
272
+ // 兼容老版本缓存:旧 lastData 里 eventFail 是**字符串**("event-down" / "no-match" / 错误原文),
273
+ // 读到时升级成 { kind, reason };新格式原样返回(否则老缓存会把"失败"错显成"未公布")。
274
+ function normalizeFail(f) {
275
+ if (!f) return null;
276
+ if (typeof f === "string") {
277
+ return /nomatch|no-match/i.test(f) ? { kind: "nomatch" } : { kind: "down", reason: normErr(f) };
278
+ }
279
+ return f.kind === "down" || f.kind === "nomatch" ? f : null;
280
+ }
281
+ // 一侧状态的单行文案:down → "卡池失败:网络不通";nomatch → "新卡池未公布";ok → ""
282
+ function sideFailText(side, fail) {
283
+ const f = normalizeFail(fail);
284
+ if (!f) return "";
285
+ return f.kind === "down"
286
+ ? SIDE_TEXT[side].fail + ":" + (f.reason || "抓取异常")
287
+ : SIDE_TEXT[side].nomatch;
288
+ }
289
+ // 逐条归类(固定顺序:卡池在前、活动在后;每侧至多一条)
290
+ function entryFailParts(r) {
291
+ const parts = [];
292
+ const gf = normalizeFail(r && r.gachaFail);
293
+ const ef = normalizeFail(r && r.eventFail);
294
+ if (gf) parts.push({ side: "gacha", kind: gf.kind, text: sideFailText("gacha", gf) });
295
+ if (ef) parts.push({ side: "event", kind: ef.kind, text: sideFailText("event", ef) });
296
+ return parts;
297
+ }
298
+ // —— 备选源(altSources / eventAltSources)字段约定 ——
299
+ // label 设置页显示名
300
+ // fetcher 抓取器键名(GACHA_FETCHERS / EVENT_FETCHERS[条目 id] 里注册)
301
+ // url 该来源要抓的地址(大多数备选源)
302
+ // id 抓取器自带地址的内部来源(如 GameKee)的稳定标识
303
+ // 设置页持久化的"当前来源"就是 url ?? id(不再用伪地址或前缀编码语义)。
304
+ // 老配置里的两种旧写法在读取时自动翻译,无需迁移脚本:
305
+ // "proxy:<url>"(旧版本用前缀标记"经 host 代理")、"gk-jp:" / "gk-global:"(旧伪地址)
306
+ const altSourceId = (alt) => alt.url || alt.id || "";
307
+ const LEGACY_ALT_IDS = { "gk-jp:": "ba-jp:gamekee", "gk-global:": "ba-global:gamekee" };
308
+ function normalizeSourceId(v) {
309
+ const s = String(v ?? "");
310
+ const mapped = Object.prototype.hasOwnProperty.call(LEGACY_ALT_IDS, s) ? LEGACY_ALT_IDS[s] : s;
311
+ return mapped.startsWith("proxy:") ? mapped.slice("proxy:".length) : mapped;
312
+ }
313
+ // 顶部提示(**只读 Result JSON**,不碰抓取内部状态):行内给"分类 + 条目名"(段间空格,
314
+ // 零项不显示),悬停明细逐条分行给原因。games 为按显示顺序排列的条目元信息(含 name)。
315
+ function buildScrapeInfo(games, result) {
316
+ const list = Array.isArray(games) ? games : [];
317
+ const rec = (result && result.games) || {};
318
+ // "成功"口径 = 两侧都没有**报错**(down);只有 nomatch(未命中)仍算成功
319
+ const okCount = list.filter((g) => {
320
+ const r = rec[g.id];
321
+ if (!r || r.skipped) return false;
322
+ return !isDown(r.gachaFail) && !isDown(r.eventFail);
323
+ }).length;
324
+ // 无任何来源的条目(skipped)既不算成功也不算失败,单独给一句"(跳过 k 个)"
325
+ const skippedCount = list.filter((g) => rec[g.id] && rec[g.id].skipped).length;
326
+ const skippedNote = skippedCount > 0 ? `(跳过 ${skippedCount} 个)` : "";
327
+ // 固定类别顺序:卡池失败 / 活动失败 / 新卡池未公布 / 新活动未公布
328
+ const CATS = [
329
+ ["gachaFail", "down", SIDE_TEXT.gacha.fail],
330
+ ["eventFail", "down", SIDE_TEXT.event.fail],
331
+ ["gachaFail", "nomatch", SIDE_TEXT.gacha.nomatch],
332
+ ["eventFail", "nomatch", SIDE_TEXT.event.nomatch]
333
+ ];
334
+ const groups = CATS.map(([field, kind, label]) => ({
335
+ label,
336
+ names: list.filter((g) => {
337
+ const f = normalizeFail(rec[g.id] && rec[g.id][field]);
338
+ return f && f.kind === kind;
339
+ }).map((g) => g.name)
340
+ })).filter((grp) => grp.names.length > 0);
341
+ let info = `成功 ${okCount}/${list.length}${skippedNote}`;
342
+ groups.forEach((grp) => { info += ` ${grp.label}:${grp.names.join("、")}`; });
343
+ const lines = list.map((g) => {
344
+ const parts = entryFailParts(rec[g.id] || {});
345
+ return parts.length > 0 ? `${g.name} ${parts.map((p) => p.text).join("、")}` : "";
346
+ }).filter(Boolean);
347
+ return { info, lines };
348
+ }
349
+
350
+ // 按设置中的排序(order: id 数组)重排;未设置时保持 SOURCES 顺序
351
+ function applyOrder(sources, order) {
352
+ if (!Array.isArray(order) || order.length === 0) return sources;
353
+ const byId = new Map(sources.map((s) => [s.id, s]));
354
+ const out = [];
355
+ for (const id of order) if (byId.has(id)) out.push(byId.get(id));
356
+ for (const s of sources) if (!out.includes(s)) out.push(s);
357
+ return out;
358
+ }
359
+
360
+ // 安全解析 JSON 字符串
361
+ function parseJsonStr(str, fallback) {
362
+ try { return JSON.parse(str || "") ?? fallback; } catch { return fallback; }
363
+ }
364
+
365
+ // 全部条目 = 内置(去除已删除) + 自定义条目
366
+ function getAllEntries(s) {
367
+ const removed = Array.isArray(s.removed) ? s.removed : [];
368
+ const base = SOURCES.filter((x) => !removed.includes(x.id));
369
+ const customs = parseJsonStr(s.customEntries, []);
370
+ if (!Array.isArray(customs)) return base;
371
+ const out = base.slice();
372
+ for (const c of customs) {
373
+ if (!c || typeof c.id !== "string") continue;
374
+ out.push({
375
+ id: c.id,
376
+ name: c.name || c.id,
377
+ banner: c.banner || "",
378
+ bannerDates: c.bannerDates || "",
379
+ event: c.event || "",
380
+ eventDates: c.eventDates || "",
381
+ next: c.next || "",
382
+ icon: c.icon || "",
383
+ source: c.source || "",
384
+ url: c.url || "",
385
+ eventUrl: c.eventUrl || "",
386
+ custom: true
387
+ });
388
+ }
389
+ return out;
390
+ }
391
+
392
+ // 蔚蓝档案三服角色名特例(一处规则、两个效果合并):
393
+ // 1) 括号后缀是换装版本标识(桔梗(泳装)、椿(導覽員)、Shiroko (Cycling)),不去除——去掉会与基础版撞名;
394
+ // 2) 括号一律归一为半角(全角()、半角() 都写成 ()),与日服/国际服源站写法对齐。
395
+ // 其它游戏仍按原规则删掉(属性/职业)后缀(如 克拉蕾(锋御·电)→ 克拉蕾)。
396
+ const BA_ROLE_IDS = ["ba-cn", "ba-global", "ba-jp"];
397
+
398
+ function baRoleName(name) {
399
+ return String(name || "").replace(/(/g, "(").replace(/)/g, ")");
400
+ }
401
+
402
+ // 角色名精简(面板外显用):去「」装饰、去(属性/职业)后缀;
403
+ // 「称号·名字」按分隔符去称号(· U+00B7 不限字数;• U+2022 仅 4 字前缀)。
404
+ // 悬停全文仍用原始 roles。
405
+ function cleanRoleNames(roles, gameId) {
406
+ const isBa = BA_ROLE_IDS.includes(gameId);
407
+ return String(roles || "")
408
+ .split(/[、,,]/)
409
+ .map((n) => {
410
+ let s = n.replace(/[「」【】]/g, "");
411
+ if (isBa) s = baRoleName(s); // 蔚蓝三服:保留括号后缀 + 统一半角
412
+ else s = s.replace(/[((][^))]*[))]/g, "");
413
+ s = s.trim();
414
+ // 称号去前缀,按分隔符区分(避免误删角色名):
415
+ // · U+00B7 居中点 = 称号分隔(原神「轰隆雷鸣波·伊涅芙」→ 伊涅芙),不限称号字数;
416
+ // • U+2022 间隔号 = 角色·变体(星铁「砂金•戏浪」→ 保留),仅在旧规则(4 字前缀)下才去
417
+ const mDot = s.match(/^([\u4e00-\u9fff]{2,10})[\u00B7](.+)$/);
418
+ if (mDot && mDot[2].trim()) {
419
+ s = mDot[2].trim();
420
+ } else {
421
+ const mBullet = s.match(/^([\u4e00-\u9fff]{4})[\u2022](.+)$/);
422
+ if (mBullet) s = mBullet[2].trim();
423
+ }
424
+ return s;
425
+ })
426
+ .filter(Boolean)
427
+ .join("、");
428
+ }
429
+
430
+ // 默认爬取源显示名(设置页下拉默认项)
431
+ // urlField: "url"(卡池源)或 "eventUrl"(活动源)
432
+ // 卡池源:source 字段,否则域名/未配置
433
+ // 活动源:eventSource 标签(或域名);无活动源 → "未配置"
434
+ function getDefaultSourceName(g, urlField) {
435
+ const isEvent = urlField === "eventUrl";
436
+ const u = isEvent ? g.eventUrl : g.url;
437
+ if (isEvent) {
438
+ if (g.eventSource && g.eventSource.trim() !== "") return g.eventSource.trim();
439
+ if (u && u.trim() !== "") {
440
+ try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u.trim(); }
441
+ }
442
+ return "未配置";
443
+ }
444
+ if (g.source && g.source.trim() !== "") return g.source.trim();
445
+ if (u && u.trim() !== "") {
446
+ try { return new URL(u).hostname.replace(/^www\./, ""); } catch { return u.trim(); }
447
+ }
448
+ return "未配置";
449
+ }
450
+
451
+ // 可见条目 = 全部条目 - 隐藏条目
452
+ function getVisibleEntries(s) {
453
+ const hidden = Array.isArray(s.hidden) ? s.hidden : [];
454
+ return getAllEntries(s).filter((x) => !hidden.includes(x.id));
455
+ }
456
+
457
+ // 某条目的实际爬取地址:自定义覆盖默认;urlField 区分卡池源(customUrls)/活动源(customEventUrls)
458
+ // 存储值约定:custom:<url>(用户自定义输入,剥前缀返回 url);其它值经 normalizeSourceId 归一
459
+ // (备选源的标识就是它的 url 或 id,老配置的 proxy:/gk-*: 写法在这里翻译)
460
+ function getEntryUrl(s, id, fallbackUrl, urlField) {
461
+ const urls = parseJsonStr(urlField === "eventUrl" ? s.customEventUrls : s.customUrls, {});
462
+ const u = urls && typeof urls === "object" ? urls[id] : undefined;
463
+ if (typeof u !== "string" || u.trim() === "") return fallbackUrl;
464
+ if (u.startsWith("custom:")) {
465
+ const v = u.slice("custom:".length).trim();
466
+ return v !== "" ? v : fallbackUrl;
467
+ }
468
+ const normalized = normalizeSourceId(u.trim());
469
+ return normalized !== "" ? normalized : fallbackUrl;
470
+ }
471
+ // 该条目的来源地址是否由用户自己填的(设置里存的是 custom:<url>):
472
+ // 自定义网址允许通用解析兜底(用户在设置页粘 api.php 等页面时,内置解析器可能吃不下)
473
+ function isCustomSource(s, id, urlField) {
474
+ const urls = parseJsonStr(urlField === "eventUrl" ? s.customEventUrls : s.customUrls, {});
475
+ const u = urls && typeof urls === "object" ? urls[id] : undefined;
476
+ return typeof u === "string" && u.startsWith("custom:");
477
+ }
478
+
479
+ // 解析面板展示的时间段(mm-dd hh:mm ~ mm-dd hh:mm,无年份,如 "08-12 06:00 ~ 09-01 17:59")
480
+ // → { startTs, endTs };无法解析返回 null。年份按当前年补全,跨年(end 月份 < start 月份)自动 +1 年。
481
+ function parseDisplayRange(str, now) {
482
+ if (typeof str !== "string") return null;
483
+ const parts = str.split(/~/).map((x) => x.trim());
484
+ if (parts.length < 2) return null;
485
+ const parsePart = (p) => {
486
+ const m = p.match(/^(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?$/);
487
+ if (!m) return null;
488
+ const mo = Number(m[1]), d = Number(m[2]);
489
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
490
+ const h = m[3] ? Number(m[3]) : 0;
491
+ const mi = m[4] ? Number(m[4]) : 0;
492
+ return { mo, d, base: new Date(now.getFullYear(), mo - 1, d, h, mi).getTime() };
493
+ };
494
+ const a = parsePart(parts[0]);
495
+ const b = parsePart(parts[1]);
496
+ if (!a || !b) return null;
497
+ let startTs = a.base;
498
+ let endTs = b.base;
499
+ // 跨年:结束月份小于开始月份(如 12-20 ~ 01-05),结束补下一年
500
+ if (b.mo < a.mo) endTs += 365 * 24 * 60 * 60 * 1000;
501
+ return { startTs, endTs };
502
+ }
503
+
504
+ // 游戏名/图标悬停:这行数据的刷新时间(= 最近一次整行都拿到新数据的时间;
505
+ // 有列沿用旧值时保持旧时间,不谎报新时间);没有成功记录则只显示名称
506
+ function buildRowTitle(g) {
507
+ return g._okAt ? g.name + "\n刷新时间 " + new Date(g._okAt).toLocaleString() : g.name;
508
+ }
509
+
510
+ // 剩余时间文本:"X 天 X 小时 X 分钟";负数(已过)返回 null 由调用方处理
511
+ function formatRemaining(ts, now) {
512
+ const diff = ts - now.getTime();
513
+ if (diff < 0) return null;
514
+ const days = Math.floor(diff / 86400000);
515
+ const hours = Math.floor((diff % 86400000) / 3600000);
516
+ const mins = Math.floor((diff % 3600000) / 60000);
517
+ return `${days}\u5929${hours}\u5C0F\u65F6${mins}\u5206\u949F`;
518
+ }
519
+
520
+ // 面板时间列展示:未开始→"还有 X 天 X 小时 X 分钟开始";进行中→"还剩 X 天 X 小时 X 分钟";已结束→"已结束"
521
+ function displayTimeCell(raw, now) {
522
+ const r = parseDisplayRange(raw, now);
523
+ if (!r) return raw || "";
524
+ if (now.getTime() < r.startTs) {
525
+ const t = formatRemaining(r.startTs, now);
526
+ return t === null ? raw : `\u8FD8\u6709 ${t} \u5F00\u59CB`;
527
+ }
528
+ if (now.getTime() > r.endTs) return "\u5DF2\u7ED3\u675F";
529
+ const t = formatRemaining(r.endTs, now);
530
+ return t === null ? raw : `\u8FD8\u6709 ${t}`;
531
+ }
532
+ //#endregion
533
+ //#endregion
534
+
535
+ //#region engine(core 对外唯一入口:createEngine)
536
+ // core = 环境无关的"抓取 + 解析 + 缓存合并"逻辑。宿主(DSH 插件 / 浏览器扩展 / Windows /
537
+ // Android / iOS / 鸿蒙)只需注入三样东西,然后读它返回的 Result JSON 画界面:
538
+ // transport: { fetchRaw(url, opts), fetchViaProxy(url, { referer, headers, body }) }
539
+ // storage: { get(key), set(key, value) } —— 配置与缓存都走它(键名见 CONFIG_KEYS)
540
+ // now: () => 毫秒时间戳(可注入 → 单测能固定时间)
541
+ // timer: { setTimeout, clearTimeout }(可选;不传就用宿主全局的)
542
+ // 硬约束(交接文档 §13.3):core 内部不得出现 window / document / Node API / 直接 fetch /
543
+ // 直接 Date.now —— 全部经 15-env.js 的 coreEnv。违反此约束会让 Android/iOS/鸿蒙 无法嵌入。
544
+ const ENGINE_SCHEMA_VERSION = 1;
545
+
546
+ export function createEngine(env) {
547
+ const engineEnv = env || {};
548
+ // 各平台自己实现这两个方法;缺省实现只保证"不崩",不联网、不落盘
549
+ const storage = engineEnv.storage || {
550
+ async get() { return undefined; },
551
+ async set() { /* 无存储:算完就返回,不持久化 */ }
552
+ };
553
+ // engine 会读取的存储键(宿主按自己的方式实现即可;读不到就用 DEFAULT_SETTINGS 的默认值)
554
+ // order / hidden / removed / customEntries / customUrls / customEventUrls —— 配置
555
+ // lastData / lastRefresh / lastSource —— 缓存
556
+ const CONFIG_KEYS = [
557
+ "order", "hidden", "removed", "customEntries", "customUrls", "customEventUrls",
558
+ "autoRefresh", "refreshMinutes", "lastData", "lastRefresh", "lastSource"
559
+ ];
560
+
561
+ // 读取配置(缺失项回落到 10-config.js 的 DEFAULT_SETTINGS)
562
+ async function readSettings() {
563
+ const out = { ...DEFAULT_SETTINGS };
564
+ for (const k of CONFIG_KEYS) {
565
+ const v = await storage.get(k);
566
+ if (v !== undefined && v !== null) out[k] = v;
567
+ }
568
+ return out;
569
+ }
570
+
571
+ // 解析器版本号(代码元信息,不随缓存走):无服务端分发时用来定位
572
+ // 「坏了的是哪个版本的用户、哪个源」(交接文档 §13.4)。自定义条目没有维护中的解析器,故不出现在这里。
573
+ function parserVersionsOf(entries) {
574
+ const out = {};
575
+ for (const e of entries) if (typeof e.parserVersion === "number") out[e.id] = e.parserVersion;
576
+ return out;
577
+ }
578
+
579
+ // 记录归一:**Result JSON 的形状必须固定**(这才是"冻结契约")——
580
+ // 内容字段无论抓没抓到都存在(缺就是空串),消费方不必到处 `?? ""`;
581
+ // 状态字段永远在(gachaFail / eventFail / okAt,可选 gachaStale / eventStale / skipped)。
582
+ // 判断"是没抓到还是源站没内容"要把内容字段与 gachaFail/gachaStale 一起看。
583
+ const CONTENT_FIELDS = [
584
+ "banner", "roles", "bannerDates", "bannerDatesRaw", "bannerHover",
585
+ "event", "eventDates", "eventDatesRaw", "eventHover"
586
+ ];
587
+ function normalizeRecord(rec, name) {
588
+ const src = rec || {};
589
+ const out = { name: name || src.name || "" };
590
+ for (const f of CONTENT_FIELDS) out[f] = typeof src[f] === "string" ? src[f] : "";
591
+ out.gachaFail = src.gachaFail || null;
592
+ out.eventFail = src.eventFail || null;
593
+ out.gachaStale = !!src.gachaStale;
594
+ out.eventStale = !!src.eventStale;
595
+ if (src.skipped) out.skipped = true;
596
+ out.okAt = typeof src.okAt === "number" ? src.okAt : 0;
597
+ return out;
598
+ }
599
+
600
+ // 上次结果(Result JSON 形态;没有缓存时 games 为空对象,UI 直接显示静态默认值即可)
601
+ async function getCached() {
602
+ const raw = await storage.get("lastData");
603
+ const parsed = raw ? parseJsonStr(raw, {}) : {};
604
+ const byId = parsed && typeof parsed === "object" ? parsed : {};
605
+ const games = {};
606
+ // 老缓存也补齐成同一形状(缺字段填空串)——消费方只需认一种形状
607
+ for (const [id, rec] of Object.entries(byId)) games[id] = normalizeRecord(rec, rec && rec.name);
608
+ const at = await storage.get("lastRefresh");
609
+ const entries = getAllEntries(await readSettings());
610
+ return {
611
+ schemaVersion: ENGINE_SCHEMA_VERSION,
612
+ refreshedAt: Number(at) || 0,
613
+ parserVersions: parserVersionsOf(entries),
614
+ games
615
+ };
616
+ }
617
+
618
+ // 条目元信息(名字 / 图标 / 来源标签 / 可选来源清单 / 静态默认值 / 是否隐藏)。
619
+ // 各平台 UI 用它画列表;**不含抓取结果**(结果在 getCached() / refresh() 里)。
620
+ async function listGames() {
621
+ const s = await readSettings();
622
+ const hidden = Array.isArray(s.hidden) ? s.hidden : [];
623
+ return getAllEntries(s).map((g) => ({
624
+ id: g.id,
625
+ name: g.name,
626
+ icon: g.icon,
627
+ source: g.source,
628
+ eventSource: g.eventSource,
629
+ altSources: (g.altSources || []).map((a) => ({ label: a.label, value: altSourceId(a) })),
630
+ eventAltSources: (g.eventAltSources || []).map((a) => ({ label: a.label, value: altSourceId(a) })),
631
+ custom: !!g.custom,
632
+ hidden: hidden.includes(g.id),
633
+ defaults: {
634
+ banner: g.banner || "",
635
+ roles: g.roles || "",
636
+ bannerDates: g.bannerDates || "",
637
+ event: g.event || "",
638
+ eventDates: g.eventDates || ""
639
+ }
640
+ }));
641
+ }
642
+
643
+ //#region core env(环境注入缝 —— core 零宿主依赖的唯一入口)
644
+ // core(配置 / 来源 / 解析器 / 抓取器 / 刷新 / helpers)里**不允许**直接碰宿主:
645
+ // 不写 window / document / Node API,也不直接 fetch、不直接读时钟。
646
+ // 所有"联网、取当前时间、计时器"一律经过本文件里的 coreEnv,由外壳注入实现:
647
+ // DSH 插件 → 92-dsh-env.js(transport 走宿主代理 /api/gacha-calendar-proxy)
648
+ // 浏览器扩展 → background 消息转发
649
+ // Windows / Android / iOS / 鸿蒙 → 各平台原生 HTTP
650
+ // 2b 之后 coreEnv 由 createEngine({ transport, storage, now }) 注入;当前由外壳在加载时 setCoreEnv()。
651
+ let coreEnv = {
652
+ transport: null,
653
+ now: () => Date.now(),
654
+ timer: {
655
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
656
+ clearTimeout: (id) => clearTimeout(id)
657
+ }
658
+ };
659
+
660
+ // 外壳注入(可只注入一部分,其余保持默认)
661
+ function setCoreEnv(next) {
662
+ if (!next) return;
663
+ coreEnv = {
664
+ ...coreEnv,
665
+ ...next,
666
+ timer: { ...coreEnv.timer, ...(next.timer || {}) }
667
+ };
668
+ }
669
+
670
+ // 取当前时间(毫秒)。core 里判断"哪一期覆盖当前"必须用这个,不许直接 Date.now(),
671
+ // 这样各平台能注入自己的时钟、也能在测试里固定时间。
672
+ function nowMs() {
673
+ return coreEnv.now();
674
+ }
675
+
676
+ function requireTransport() {
677
+ if (!coreEnv.transport) throw new Error("core transport 未注入");
678
+ return coreEnv.transport;
679
+ }
680
+
681
+ // 直连抓取(不需要代理的源:bwiki / PRTS 等 CORS 放行的站,调用方自行加 origin=* 等参数)。
682
+ // 返回 WHATWG Response 形态的对象(有 ok / status / headers / text() / json()),
683
+ // 各平台据此包装自己的 HTTP 实现即可,调用点无需改动。
684
+ function transportFetchRaw(url, opts) {
685
+ return requireTransport().fetchRaw(url, opts);
686
+ }
687
+
688
+ // 经代理抓取文本(需要绕过 CORS / Referer 反爬的源)。语义与原 host 代理调用完全一致:
689
+ // referer / headers(可选对象)/ body(可选,提供时以 POST + JSON 发出)→ 原始 body 字符串
690
+ async function proxyFetchText(proxyUrl, referer, extraHeaders, body) {
691
+ return requireTransport().fetchViaProxy(proxyUrl, { referer, headers: extraHeaders, body });
692
+ }
693
+
694
+ // 经代理抓取 JSON
695
+ async function proxyFetchJson(proxyUrl, referer, extraHeaders, body) {
696
+ return JSON.parse(await proxyFetchText(proxyUrl, referer, extraHeaders, body));
697
+ }
698
+ //#endregion
699
+
700
+ //#region scrape parsers
701
+ // 纯函数解析器:同一 HTML 输入必然产生同一输出(确定性),
702
+ // 保证网页内容未变时手动刷新结果保持一致。
703
+ function stripTags(s) {
704
+ return (s || "")
705
+ .replace(/<br\s*\/?>/gi, " ")
706
+ .replace(/<[^>]+>/g, "")
707
+ .replace(/&amp;/g, "&")
708
+ .replace(/&lt;/g, "<")
709
+ .replace(/&gt;/g, ">")
710
+ .replace(/&quot;/g, '"')
711
+ .replace(/&#91;/g, "[")
712
+ .replace(/&#93;/g, "]")
713
+ .replace(/&#39;/g, "'")
714
+ .replace(/&#8211;/g, "\u2013")
715
+ .replace(/&#160;/g, " ")
716
+ .replace(/&nbsp;/g, " ")
717
+ .replace(/\s+/g, " ")
718
+ .trim();
719
+ }
720
+
721
+ // 解析单个时间 → {ts, text};无法解析返回 {ts:null, text:null}
722
+ function parseTime(s) {
723
+ const m = String(s).match(/(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
724
+ if (!m) return { ts: null, text: null };
725
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]), h = Number(m[4]), mi = Number(m[5]);
726
+ const text = `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
727
+ return { ts: new Date(y, mo - 1, d, h, mi).getTime(), text };
728
+ }
729
+
730
+ // 时间段 → startTs/endTs + 统一文本 mm-dd hh:mm ~ mm-dd hh:mm(无法解析的一侧保留原文)
731
+ function parseRange(raw) {
732
+ const t = stripTags(raw);
733
+ const parts = t.split(/~/).map((x) => x.trim());
734
+ if (parts.length < 2) {
735
+ const p = parseTime(parts[0]);
736
+ return { startTs: p.ts, endTs: null, startText: p.text, endText: null, raw: p.text ?? t };
737
+ }
738
+ const a = parseTime(parts[0]);
739
+ const b = parseTime(parts[1]);
740
+ return {
741
+ startTs: a.ts, endTs: b.ts,
742
+ startText: a.text ?? parts[0], endText: b.text ?? parts[1],
743
+ raw: `${a.text ?? parts[0]} ~ ${b.text ?? parts[1]}`
744
+ };
745
+ }
746
+
747
+ // 角色名清理:去首尾方括号/空白(zzz 的 [希格莉德(强攻·冰)])
748
+ function cleanRoles(roles) {
749
+ const r = stripTags(roles).replace(/^[\[【\s]+|[\]】\s]+$/g, "");
750
+ return r;
751
+ }
752
+
753
+ // 主池过滤:排除武器/光锥/音擎/回响/重映等副池
754
+ function isMainBanner(banner) {
755
+ if (!banner) return false;
756
+ if (/武器|光锥|音擎|回响|重映|神铸赋形|流光定影|溯回忆象/.test(banner)) return false;
757
+ return /角色活动祈愿|角色活动跃迁|独家频段|寻访|频段/.test(banner) || banner.includes("「");
758
+ }
759
+
760
+ // bwiki 通用:解析所有含「时间+版本」的卡池表(原神/星铁/绝区零)
761
+ function parseAllBwiki(html) {
762
+ const out = [];
763
+ const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
764
+ for (const t of tables) {
765
+ const body = t[1];
766
+ if (!/<th[^>]*>\s*时间\s*<\/th>/i.test(body)) continue;
767
+ if (!/<th[^>]*>\s*版本\s*<\/th>/i.test(body)) continue;
768
+ let banner = "";
769
+ const tc = body.match(/<th[^>]*colspan\s*=\s*"?2"?[^>]*>([\s\S]*?)<\/th>/i);
770
+ if (tc) {
771
+ const alt = tc[1].match(/<img[^>]*alt\s*=\s*"([^"]*)"/i);
772
+ banner = alt ? alt[1] : stripTags(tc[1]);
773
+ }
774
+ if (!banner) {
775
+ const t2 = body.match(/<th[^>]*>([\s\S]*?)<\/th>/i);
776
+ if (t2) banner = stripTags(t2[1]);
777
+ }
778
+ const timeM = body.match(/<th[^>]*>\s*时间\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
779
+ const charM = body.match(/<th[^>]*>\s*(?:5星角色|S级代理人|6星干员|5星干员)\s*<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/i);
780
+ if (!timeM) continue;
781
+ const range = parseRange(timeM[1]);
782
+ out.push({
783
+ banner,
784
+ roles: charM ? stripTags(charM[1]) : "",
785
+ ...range,
786
+ isMain: isMainBanner(banner)
787
+ });
788
+ }
789
+ return out;
790
+ }
791
+
792
+ // 方舟:解析「干员轮换卡池」(标准寻访/当期轮换池)所有数据行。
793
+ // 该表行结构:序号 | 寻访页面(title=寻访模拟/干员轮换卡池N) | 开启时间 | 特定干员(6星) | 特定干员(5星)。
794
+ // 兼容历史「限时寻访」表(寻访页面|开启时间|特定干员6星|特定干员5星&4星)作为兜底。
795
+ function parseArknights(html) {
796
+ const out = [];
797
+ // 标准(干员轮换卡池)+ 中坚(中坚甄选):行内 title="寻访模拟/干员轮换卡池N" 或 "寻访模拟/中坚甄选N"
798
+ for (const rm of html.matchAll(/<tr(?:[^>]*)>([\s\S]*?)<\/tr>/g)) {
799
+ const row = rm[1];
800
+ const tier = /干员轮换卡池/.test(row) ? ("标准") : (/中坚甄选/.test(row) ? "中坚" : null);
801
+ if (!tier) continue;
802
+ const tds = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1]);
803
+ if (tds.length < 3) continue;
804
+ const titleM = (tds[1] || "").match(/title="([^"]*)"/);
805
+ if (!titleM) continue;
806
+ const banner = String(titleM[1]).replace(/^寻访模拟\//, ""); // 干员轮换卡池192 / 中坚甄选14
807
+ const roles = [...((tds[3] || "") + (tds[4] || "")).matchAll(/<a[^>]*title="([^"]+)"/g)]
808
+ .map((m) => m[1]).filter(Boolean);
809
+ const range = parseRange(tds[2]);
810
+ out.push({ tier, banner, roles: roles.join("、"), ...range, isMain: true });
811
+ }
812
+ // 限时(联合行动/限定):解析「限时寻访」表
813
+ const i = html.indexOf("限时寻访");
814
+ if (i >= 0) {
815
+ const seg = html.slice(i);
816
+ const tableM = seg.match(/<table[^>]*>([\s\S]*?)<\/table>/);
817
+ if (tableM) {
818
+ const body = tableM[1];
819
+ for (const rm of body.matchAll(/<tr>([\s\S]*?<td[\s\S]*?)<\/tr>/g)) {
820
+ const row = rm[1];
821
+ const tds = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1]);
822
+ if (tds.length < 2) continue;
823
+ const banner = stripTags(tds[0]);
824
+ if (!banner) continue;
825
+ const roles = tds.length > 2
826
+ ? [...tds[2].matchAll(/<a[^>]*href="\/w\/[^"]*"[^>]*title="([^"]*)"/g)]
827
+ .map((m) => m[1].trim()).filter(Boolean).join("、")
828
+ : "";
829
+ const range = parseRange(tds[1]);
830
+ out.push({ tier: "限时", banner, roles, ...range, isMain: true });
831
+ }
832
+ }
833
+ }
834
+ return out;
835
+ }
836
+
837
+ // 明日方舟当期卡池:外显按 限时 > 标准 > 中坚 优先级选一个;悬停 bannerHover 列出三种。
838
+ function selectArknights(html, now = nowMs()) {
839
+ const items = parseArknights(html);
840
+ fillMissingStarts(items);
841
+ const tiers = ["限时", "标准", "中坚"];
842
+ const curByTier = {};
843
+ for (const t of tiers) {
844
+ curByTier[t] = items.filter((it) => it.tier === t && it.startTs != null && it.startTs <= now && it.endTs != null && it.endTs >= now);
845
+ }
846
+ let winner = null;
847
+ for (const t of tiers) {
848
+ if (curByTier[t].length > 0) { winner = curByTier[t][0]; break; }
849
+ }
850
+ if (!winner) return null;
851
+ const lines = tiers.map((t) => {
852
+ const arr = curByTier[t];
853
+ if (arr.length === 0) return `${t}:(无)`;
854
+ const it = arr.slice().sort((a, b) => b.startTs - a.startTs)[0];
855
+ return `${t}:${it.banner}${it.roles ? `\uFF1A${it.roles}` : ""}\n${it.rawOriginal || it.raw}`;
856
+ });
857
+ return {
858
+ banner: winner.banner,
859
+ roles: winner.roles,
860
+ bannerDates: winner.raw,
861
+ bannerDatesRaw: winner.rawOriginal || winner.raw,
862
+ bannerHover: lines.join("\n")
863
+ };
864
+ }
865
+
866
+ // ---- 明日方舟官方公告(web-news.hypergryph.com CMS,code=arknights)----
867
+ // 官方 CMS:列表接口的 brief 被服务端截断(只有时间与开头几个 UP),角色全量需按 cid 取详情。
868
+ // 标题格式:[家族]【池名】限时寻访(即将)开启;brief 首段即"活动时间:X月X日 HH:mm - X月X日 HH:mm"。
869
+
870
+ // 从公告正文/brief 提取 UP 干员("★★★★★★:结城理(占…)★★★★★:埃癸斯 / 岳羽由加莉(…)")
871
+ function parseAkOfficialRoles(text) {
872
+ const s = String(text || "")
873
+ .replace(/<[^>]+>/g, " ")
874
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
875
+ .replace(/\\/g, " ")
876
+ .replace(/\s+/g, " ");
877
+ const segM = s.match(/(?:出现率上升|获得概率提升)\s*([\s\S]*?)(?:注意|抽取概率公示|在本期|$)/);
878
+ const seg = segM ? segM[1] : s;
879
+ const names = [];
880
+ for (const um of seg.matchAll(/(?:★{3,6})\s*[::]\s*([^★((\n]+)/g)) {
881
+ for (let tok of String(um[1]).split(/[\/、,,\\\s]+/)) {
882
+ tok = tok.replace(/\[[^\]]*\]/g, "").trim();
883
+ if (tok && !/^[0-9.%占出率概]+$/.test(tok) && tok.length >= 2 && tok.length <= 10) names.push(tok);
884
+ }
885
+ }
886
+ return [...new Set(names)].join("、");
887
+ }
888
+
889
+ // 列表 → 当期 限时/联动寻访池数组(bannerDates 统一为 "MM-DD HH:mm ~ MM-DD HH:mm")
890
+ function parseAkOfficialPools(list, now = nowMs()) {
891
+ const nowYear = new Date(now).getFullYear();
892
+ const out = [];
893
+ for (const it of list || []) {
894
+ const title = String(it.title || "");
895
+ const brief = String(it.brief || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
896
+ if (!/寻访/.test(title) || !/活动时间/.test(brief)) continue;
897
+ const nameM = title.match(/【([^】]+)】\s*限时寻访/);
898
+ if (!nameM) continue;
899
+ const timeM = brief.match(/活动时间\s*[::]\s*(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})\s*[-—~]\s*(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
900
+ if (!timeM) continue;
901
+ const mk = (y, mo, d, h, mi) => new Date(y, mo - 1, d, h, mi).getTime();
902
+ const sm = Number(timeM[1]), sd = Number(timeM[2]);
903
+ const em = Number(timeM[5]), ed = Number(timeM[6]);
904
+ const startTs = mk(nowYear, sm, sd, Number(timeM[3]), Number(timeM[4]));
905
+ const endTs = mk(em < sm ? nowYear + 1 : nowYear, em, ed, Number(timeM[7]), Number(timeM[8]));
906
+ if (startTs > now || endTs < now) continue; // 只要当期覆盖
907
+ const fmt = (mo, d, h, mi) => `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
908
+ const startText = fmt(sm, sd, Number(timeM[3]), Number(timeM[4]));
909
+ const endText = fmt(em, ed, Number(timeM[7]), Number(timeM[8]));
910
+ const raw = `${startText} ~ ${endText}`;
911
+ const familyM = title.match(/^\[([^\]]+)\]/);
912
+ out.push({
913
+ cid: String(it.cid || ""),
914
+ family: familyM ? familyM[1] : "",
915
+ banner: nameM[1],
916
+ roles: parseAkOfficialRoles(brief),
917
+ bannerDates: raw,
918
+ bannerDatesRaw: raw,
919
+ startTs, endTs, startText, endText, raw,
920
+ tier: "限时",
921
+ isMain: true
922
+ });
923
+ }
924
+ return out;
925
+ }
926
+
927
+ // 官方当期限时寻访(经 host 代理:列表 + 每池详情各 1 次请求)
928
+ async function fetchArknightsOfficialPools(signal, now = nowMs()) {
929
+ const ref = "https://ak.hypergryph.com/";
930
+ const listUrl = AK_OFFICIAL_BULLETIN + "?lang=zh-cn&code=arknights&page=1&pageSize=30";
931
+ const json = await proxyFetchJson(listUrl, ref);
932
+ const list = Array.isArray(json?.data?.list) ? json.data.list : [];
933
+ const pools = parseAkOfficialPools(list, now);
934
+ await Promise.all(pools.map(async (p) => {
935
+ try {
936
+ const d = await proxyFetchJson(`${AK_OFFICIAL_BULLETIN}/${p.cid}?lang=zh-cn&code=arknights`, ref);
937
+ const content = typeof d?.data?.data === "string" ? d.data.data : "";
938
+ if (content) {
939
+ const roles = parseAkOfficialRoles(content);
940
+ if (roles) p.roles = roles;
941
+ }
942
+ } catch { /* 详情失败保留 brief 解析的角色 */ }
943
+ }));
944
+ return pools;
945
+ }
946
+
947
+ // 方舟卡池默认抓取器:官方公告 CMS 优先(当期限时/联动寻访),
948
+ // 官方无当期寻访公告(常规轮换周)或官方失败时自动回退 PRTS 卡池一览(逻辑同 selectArknights)。
949
+ // 设置页手动切到 PRTS 备选源时则走 GACHA_FETCHERS["arknights-prts"],不经本函数。
950
+ async function fetchArknightsGacha(url, signal, now = nowMs()) {
951
+ try {
952
+ const official = await fetchArknightsOfficialPools(signal, now);
953
+ if (official.length > 0) {
954
+ const p = official[0];
955
+ // 悬停:与其它游戏统一为「池名:角色」+ 时间(多池时逐池一行、同窗口合并时间、结束时间升序)
956
+ const pools = official.map((x) => ({
957
+ name: x.banner,
958
+ label: `${x.banner}${x.roles ? `\uFF1A${x.roles}` : (x.family ? `\uFF1A${x.family}` : "")}`,
959
+ startTs: x.startTs,
960
+ endTs: x.endTs,
961
+ raw: x.raw || x.bannerDatesRaw || x.bannerDates
962
+ }));
963
+ return {
964
+ banner: p.banner,
965
+ roles: p.roles,
966
+ bannerDates: p.bannerDates,
967
+ bannerDatesRaw: p.bannerDatesRaw,
968
+ bannerHover: buildPoolHover(pools)
969
+ };
970
+ }
971
+ } catch { /* 官方失败 → 回退 PRTS */ }
972
+ const apiUrl = ARKNIGHTS_PRTS_URL + (ARKNIGHTS_PRTS_URL.includes("?") ? "&" : "?") + "origin=*";
973
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
974
+ if (!res.ok) throw new Error("http-" + res.status);
975
+ const json = await res.json();
976
+ const html = json?.parse?.text;
977
+ if (typeof html !== "string") throw new Error("bad-json");
978
+ const d = selectArknights(html, now);
979
+ return d ? { ...d } : null;
980
+ }
981
+
982
+ // 通用规则:起始时间为"版本更新后"等无具体日期的卡池,继承前一组(按结束时间分组)
983
+ // 卡池的结束时间 —— 同一维护时间点(如星铁 4.5 上半的"4.5版本更新后" = 4.4 下半的结束时间)。
984
+ // 不针对任何游戏特判:所有经 selectCurrent 的解析器统一受益。
985
+ // 补全时保留源站原文(rawOriginal),供面板悬停显示原文(倒计时仍用补全后的时间)。
986
+ function fillMissingStarts(items) {
987
+ const byEnd = items.filter((it) => it.endTs != null).slice().sort((a, b) => a.endTs - b.endTs);
988
+ let groupEnd = null, groupText = null;
989
+ let i = 0;
990
+ while (i < byEnd.length) {
991
+ let j = i;
992
+ while (j < byEnd.length && byEnd[j].endTs === byEnd[i].endTs) j++; // 相同结束时间成组
993
+ if (groupEnd != null) {
994
+ for (let k = i; k < j; k++) {
995
+ const cur = byEnd[k];
996
+ if (cur.startTs == null && groupText) {
997
+ if (!cur.rawOriginal) cur.rawOriginal = cur.raw; // 保留源站原文(如"4.5版本更新后 ~ …")
998
+ cur.startTs = groupEnd;
999
+ cur.startText = groupText;
1000
+ cur.raw = `${groupText} ~ ${cur.endText ?? ""}`.trim();
1001
+ }
1002
+ }
1003
+ }
1004
+ groupEnd = byEnd[i].endTs;
1005
+ groupText = byEnd[i].endText;
1006
+ i = j;
1007
+ }
1008
+ }
1009
+
1010
+ // 选当期:优先"起始明确且覆盖 now"的主池;
1011
+ // 其次"起始未知(版本更新后)但结束在未来"的主池(星铁/zzz 上半,此时起始已被 fillMissingStarts 补齐);
1012
+ // 同期多张主池(如 104期+104-2期)合并角色。返回 null 时调用方回退内置数据。
1013
+ // bannerDates 为补全后用于倒计时的文本;bannerDatesRaw 为源站原文(悬停展示)。
1014
+ function selectCurrent(items, now) {
1015
+ fillMissingStarts(items);
1016
+ const exact = items.filter((it) => it.startTs != null && it.startTs <= now && it.endTs != null && it.endTs >= now);
1017
+ const loose = items.filter((it) => it.startTs == null && it.endTs != null && it.endTs >= now);
1018
+ const pool = (exact.length > 0 ? exact : loose).filter((it) => it.isMain);
1019
+ if (pool.length === 0) return null;
1020
+ const first = pool[0];
1021
+ const sameRange = pool.filter((it) => it.startTs === first.startTs && it.endTs === first.endTs);
1022
+ const roles = [...new Set(sameRange.map((it) => cleanRoles(it.roles)).filter(Boolean))].join("、");
1023
+ return {
1024
+ banner: first.banner,
1025
+ roles,
1026
+ bannerDates: first.raw,
1027
+ bannerDatesRaw: first.rawOriginal || first.raw
1028
+ };
1029
+ }
1030
+
1031
+ // MM-DD HH:MM(同年窗口用)
1032
+ function fmtMdHm(ts) {
1033
+ const d = new Date(ts);
1034
+ 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")}`;
1035
+ }
1036
+
1037
+ // YYYY-MM-DD HH:MM(跨年窗口用:避免"05-15 16:00 ~ 05-15 03:59"看着像结束早于开始)
1038
+ function fmtYmdHm(ts) {
1039
+ const d = new Date(ts);
1040
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1041
+ }
1042
+
1043
+ // 窗口起止文本:两端同一年 → MM-DD;跨年 → 两端都带年份
1044
+ function fmtWindow(startTs, endTs) {
1045
+ const sameYear = new Date(startTs).getFullYear() === new Date(endTs).getFullYear();
1046
+ return sameYear ? `${fmtMdHm(startTs)} ~ ${fmtMdHm(endTs)}` : `${fmtYmdHm(startTs)} ~ ${fmtYmdHm(endTs)}`;
1047
+ }
1048
+
1049
+ // 长期/常驻玩法判定:声明窗口超过该天数的不当作"当期活动"(外显与悬停共用,①)。
1050
+ // 依据:各游戏限时活动实测最长约 84 天(原神),而常驻玩法动辄半年以上——
1051
+ // 明日方舟 PRTS 活动一览里「生息演算:重启锚点」245 天、「集成战略:沉沦者的黑流树海」179 天,
1052
+ // 两者都是常驻玩法(表内含"进行中"徽标),且因外显不带年份会被误读成"结束早于开始"。
1053
+ const EVENT_MAX_WINDOW_DAYS = 120;
1054
+
1055
+ function isLongTermEvent(x) {
1056
+ return !!x && x.startTs != null && x.endTs != null && (x.endTs - x.startTs) > EVENT_MAX_WINDOW_DAYS * 864e5;
1057
+ }
1058
+
1059
+ // 活动列表统一排序(③):结束时间升序(越快结束越靠前);结束时间无/未知/未抓到的排最后;
1060
+ // 同结束时间再按开始时间升序。同时剔除常驻/长期玩法与空名行(①)。
1061
+ // 外显取排序后的第一条,悬停按同序逐行展示 —— 两处共用本函数保证一致。
1062
+ function sortEventItems(items) {
1063
+ return (Array.isArray(items) ? items : [])
1064
+ .filter((x) => x && typeof x.name === "string" && x.name.trim() !== "")
1065
+ .filter((x) => !isLongTermEvent(x))
1066
+ .sort((a, b) => {
1067
+ const ea = a.endTs == null ? Infinity : a.endTs;
1068
+ const eb = b.endTs == null ? Infinity : b.endTs;
1069
+ if (ea !== eb) return ea - eb;
1070
+ const sa = a.startTs == null ? Infinity : a.startTs;
1071
+ const sb = b.startTs == null ? Infinity : b.startTs;
1072
+ return sa - sb;
1073
+ });
1074
+ }
1075
+
1076
+ // 活动类别优先级(只影响"外显"挑选,不影响悬停排序):
1077
+ // 第一档 = 剧情/叙事类活动 + 限时高难玩法(剧情/叙事/故事、总力战/大决战、危机合约、挑战活动…);
1078
+ // 其余为第二档。判定优先级:有类别字段(原神 SMW「类型」、星铁/绝区零表「类型」列、方舟分类前缀、
1079
+ // 终末地 tags、蔚蓝国际服 cat 列)就按类别判定;源头没有类别信息时才回退按活动名匹配关键词。
1080
+ const EVENT_TIER1_RE = /剧情|叙事|主线|故事|活动正篇|总力战|總力戰|大决战|大決戰|决战|決戰|危机合约|危機合約|制约解除|综合战术|綜合戰術|挑战|挑戰|深度巡防|极限|逆境深塔|冥歌海墟|全息/;
1081
+
1082
+ function eventTier(x) {
1083
+ const cat = `${x?.cat || ""} ${x?.tags || ""}`.trim();
1084
+ const hay = cat || `${x?.name || ""}`;
1085
+ return EVENT_TIER1_RE.test(hay) ? 1 : 2;
1086
+ }
1087
+
1088
+ // 外显挑选:先按类别档位(第一档优先),同档内按结束时间升序(③ 越快结束越靠前,
1089
+ // 结束时间未知排最后);悬停仍按 endTs 升序全量展示,不受类别影响。
1090
+ function pickEventPrimary(items) {
1091
+ return (Array.isArray(items) ? items : []).slice().sort((a, b) => {
1092
+ const t = eventTier(a) - eventTier(b);
1093
+ if (t !== 0) return t;
1094
+ const ea = a.endTs == null ? Infinity : a.endTs;
1095
+ const eb = b.endTs == null ? Infinity : b.endTs;
1096
+ if (ea !== eb) return ea - eb;
1097
+ const sa = a.startTs == null ? Infinity : a.startTs;
1098
+ const sb = b.startTs == null ? Infinity : b.startTs;
1099
+ return sa - sb;
1100
+ })[0] || null;
1101
+ }
1102
+
1103
+ // 活动列悬停(鸣潮式多行):按传入顺序(调用方已 sortEventItems)每条一行;
1104
+ // 各行窗口完全相同 → 时间只在末尾写一遍;缺起止的行原样显示该行原文;跨年窗口两端带年份(②)。
1105
+ // 兜底:只有 0/1 条时返回 "",由 UI 退回原有"活动名 + 时间"单条展示 ——
1106
+ // 公告类单条源(蔚蓝国服/日服、1999、异环等)因此完全不受影响,也不会出现空行或半截区间。
1107
+ function buildEventHover(items) {
1108
+ const list = (Array.isArray(items) ? items : [])
1109
+ .filter((x) => x && typeof x.name === "string" && x.name.trim() !== "")
1110
+ .filter((x) => !isLongTermEvent(x));
1111
+ if (list.length < 2) return "";
1112
+ const allTimed = list.every((x) => x.startTs != null && x.endTs != null);
1113
+ const same = allTimed && new Set(list.map((x) => `${x.startTs}~${x.endTs}`)).size === 1;
1114
+ const lines = list.map((x) => {
1115
+ // 有起止的行按行带时间(全部同窗口时只在末尾写一遍);缺起止的行原样显示该行原文
1116
+ if (x.startTs != null && x.endTs != null) {
1117
+ return same ? x.name : `${x.name} ${fmtWindow(x.startTs, x.endTs)}`;
1118
+ }
1119
+ const raw = String(x.raw || "").trim();
1120
+ return raw ? `${x.name} ${raw}` : x.name;
1121
+ });
1122
+ if (same) lines.push(fmtWindow(list[0].startTs, list[0].endTs));
1123
+ return lines.join("\n");
1124
+ }
1125
+
1126
+ // 卡池列悬停(统一格式,沿用方舟/面板既有"换行"排版):
1127
+ // 每池两行 —— "池名:角色" + "起止时间";窗口完全相同的池合并时间(只在末尾写一遍);
1128
+ // 排序:结束时间升序(无/未知结束时间排最后)。
1129
+ // 外显不受此函数影响:仍按各源原有逻辑(同窗口角色合并)。兜底:0/1 池返回 "",退回单条展示。
1130
+ function buildPoolHover(pools) {
1131
+ const list = (Array.isArray(pools) ? pools : [])
1132
+ .filter((p) => p && typeof p.name === "string" && p.name.trim() !== "")
1133
+ .sort((a, b) => {
1134
+ const ea = a.endTs == null ? Infinity : a.endTs;
1135
+ const eb = b.endTs == null ? Infinity : b.endTs;
1136
+ if (ea !== eb) return ea - eb;
1137
+ const sa = a.startTs == null ? Infinity : a.startTs;
1138
+ const sb = b.startTs == null ? Infinity : b.startTs;
1139
+ return sa - sb;
1140
+ });
1141
+ if (list.length < 2) return "";
1142
+ const allTimed = list.every((p) => p.startTs != null && p.endTs != null);
1143
+ const same = allTimed && new Set(list.map((p) => `${p.startTs}~${p.endTs}`)).size === 1;
1144
+ const lines = [];
1145
+ for (const p of list) {
1146
+ lines.push(p.label || p.name);
1147
+ if (same) continue;
1148
+ const t = p.startTs != null && p.endTs != null ? fmtWindow(p.startTs, p.endTs) : String(p.raw || "").trim();
1149
+ if (t) lines.push(t);
1150
+ }
1151
+ if (same) lines.push(fmtWindow(list[0].startTs, list[0].endTs));
1152
+ return lines.join("\n");
1153
+ }
1154
+
1155
+ // 鸣潮:角色轮换池页 → data-start/data-end + 当期角色
1156
+ function parseWuwaPool(html) {
1157
+ const tm = html.match(/data-start="([^"]+)"\s+data-end="([^"]+)"/);
1158
+ if (!tm) return null;
1159
+ const start = parseTime(tm[1]);
1160
+ const end = parseTime(tm[2]);
1161
+ const chars = [...new Set([...html.matchAll(/共鸣者\/([^"]+)"/g)].map((m) => m[1]))].slice(0, 6);
1162
+ return {
1163
+ banner: "\u89D2\u8272\u6362\u53EC\u6C60",
1164
+ roles: chars.join("、"),
1165
+ startTs: start.ts, endTs: end.ts,
1166
+ bannerDates: start.text && end.text ? `${start.text} ~ ${end.text}` : ""
1167
+ };
1168
+ }
1169
+
1170
+ // 鸣潮官方公告解析:从全量公告(game/activity/recommend)中取"覆盖当前时刻"的「角色活动唤取」
1171
+ // 公告形如:tabTitle="[身赴三途]角色活动唤取",content 内含"✦活动时间✦ 2026年9月10日10:00 ~ 2026年9月29日11:59"
1172
+ function parseWuwaNotice(list, now = nowMs()) {
1173
+ const groups = [list?.game, list?.activity, list?.recommend].filter(Array.isArray);
1174
+ const stripH = (s) => String(s || "")
1175
+ .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
1176
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&hellip;/g, "…")
1177
+ .replace(/&ldquo;/g, "“").replace(/&rdquo;/g, "”")
1178
+ .replace(/[ \t]+/g, " ").replace(/\n\s*\n+/g, "\n").trim();
1179
+ const pools = [];
1180
+ for (const arr of groups) {
1181
+ for (const it of arr) {
1182
+ const title = stripH(it.tabTitle || it.title || "");
1183
+ if (!/活动唤取/.test(title)) continue;
1184
+ const text = stripH(it.content || "");
1185
+ const m = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})\s*[~~-]\s*(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日\s*(\d{1,2}):(\d{2})/);
1186
+ if (!m) continue;
1187
+ const sTs = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
1188
+ const eTs = new Date(+(m[6] || m[1]), +m[7] - 1, +m[8], +m[9], +m[10]).getTime();
1189
+ if (sTs > now || eTs < now) continue; // 只要当期覆盖
1190
+ const name = title
1191
+ .replace(/\s*(?:角色|武器)活动唤取\s*$/, "")
1192
+ .replace(/^[\[【「]\s*/, "")
1193
+ .replace(/\s*[\]】」]$/, "")
1194
+ .trim();
1195
+ const upPart = text.split(/✦\s*活动时间/)[0] || "";
1196
+ const ups = [...upPart.matchAll(/[「【]([^」】]+)[」】]/g)].map((x) => x[1].trim()).filter(Boolean);
1197
+ pools.push({ name, isChar: /角色活动唤取/.test(title), roles: ups.join("、"), startTs: sTs, endTs: eTs });
1198
+ }
1199
+ }
1200
+ const cur = pools.filter((p) => p.isChar && p.name);
1201
+ if (cur.length === 0) return null;
1202
+ const first = cur[0];
1203
+ const roles = [...new Set(cur.flatMap((p) => p.roles.split("、")).filter(Boolean))].join("、");
1204
+ const fmt = (ts) => {
1205
+ const d = new Date(ts);
1206
+ 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")}`;
1207
+ };
1208
+ const raw = fmtWindow(first.startTs, first.endTs);
1209
+ // 悬停:每池"池名:角色"一行 + 时间一行;各池窗口相同则时间只在末尾写一遍;按结束时间升序
1210
+ const bannerHover = buildPoolHover(cur.map((p) => ({
1211
+ name: p.name,
1212
+ label: `${p.name}\uFF1A${p.roles || "-"}`,
1213
+ startTs: p.startTs,
1214
+ endTs: p.endTs
1215
+ })));
1216
+ return { banner: first.name, roles, bannerDates: raw, bannerDatesRaw: raw, startTs: first.startTs, endTs: first.endTs, bannerHover };
1217
+ }
1218
+
1219
+ // 鸣潮卡池默认抓取器:官方公告(entrypoint → 目录 → zh-Hans.json 全量)优先;
1220
+ // 无当期公告 / 抓取失败 → 自动回退 Bwiki 角色轮换池(逻辑同 parseWuwaPool)
1221
+ async function fetchWuwaGacha(entryUrl, signal, now = nowMs()) {
1222
+ try {
1223
+ const ref = "https://aki-gm-resources.aki-game.com/";
1224
+ const ej = await proxyFetchJson(entryUrl, ref);
1225
+ const contentUrl = Array.isArray(ej?.contentUrl) ? ej.contentUrl[0] : "";
1226
+ const dir = contentUrl ? contentUrl.replace(/[^/]*$/, "") : String(entryUrl).replace(/[^/]*$/, "");
1227
+ let list = null;
1228
+ try { list = await proxyFetchJson(dir + "zh-Hans.json", ref); } catch { list = null; }
1229
+ if (!list || typeof list !== "object") list = await proxyFetchJson(dir + "notice.json", ref);
1230
+ const d = parseWuwaNotice(list, now);
1231
+ if (d) return d;
1232
+ } catch { /* 官方失败 → Bwiki 备选 */ }
1233
+ const apiUrl = WUWA_BWIKI_URL + (WUWA_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
1234
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
1235
+ if (!res.ok) throw new Error("http-" + res.status);
1236
+ const json = await res.json();
1237
+ const html = json?.parse?.text;
1238
+ if (typeof html !== "string") throw new Error("bad-json");
1239
+ return parseWuwaPool(html);
1240
+ }
1241
+
1242
+ // 绝区零官网公告解析:从公告频道(iChanId=279)取「X.Y版本限时频段(上/下期)」,选覆盖当前时刻的一期。
1243
+ // 公告形如:sIntro="本期代理人与音擎调频活动时间为:3.2版本更新后 ~ 2026/09/30 11:59",
1244
+ // sContent 内含「活动期间,限定S级代理人[克拉蕾(电·锋御)]、[南宫羽(以太·击破)]…」。
1245
+ // 起点为"版本更新后"时,用同版本「更新公告」的 dtStartTime 补全;「独家重映/音擎回响」自选段跳过。
1246
+ function parseZzzFreq(payload, now = nowMs()) {
1247
+ const list = Array.isArray(payload?.data?.list) ? payload.data.list : [];
1248
+ const clean = (s) => stripTags(s);
1249
+ // 版本更新公告 → "X.Y版本更新后"的起点;同时抽取「S级代理人[X] → 「Y」频段」对应表
1250
+ // (频段名只写在更新公告里,逐期频段公告不含频段名,故用它回填卡池标题)
1251
+ const verStart = {};
1252
+ const poolOf = {};
1253
+ for (const it of list) {
1254
+ const t = clean(it?.sTitle);
1255
+ const vm = t.match(/(\d+\.\d+)\s*版本/);
1256
+ if (!vm) continue;
1257
+ if (/更新(?:公告|通知)/.test(t)) {
1258
+ const p = parseTime(it.dtStartTime);
1259
+ if (p.ts != null && verStart[vm[1]] == null) verStart[vm[1]] = p;
1260
+ }
1261
+ const text = clean(it.sIntro) + " " + clean(it.sContent);
1262
+ for (const m of text.matchAll(/S级代理人\s*[\[【]([^\]】]+)[\]】][^「]{0,40}?「([^」]{2,14})」频段/g)) {
1263
+ const name = m[1].replace(/[((].*$/, "").trim();
1264
+ if (name && poolOf[name] == null) poolOf[name] = m[2].trim();
1265
+ }
1266
+ }
1267
+ // 角色名统一为「职业·属性」全角括号(与 Bwiki 显示一致):克拉蕾(电·锋御) → 克拉蕾(锋御·电)
1268
+ const normRole = (name) => {
1269
+ const m = name.match(/^([^(()]+)[((]([^))]+)[))]\s*$/);
1270
+ if (!m) return name;
1271
+ const bits = m[2].split("·");
1272
+ return bits.length === 2 ? `${m[1]}(${bits[1]}·${bits[0]})` : `${m[1]}(${m[2]})`;
1273
+ };
1274
+ const pools = [];
1275
+ for (const it of list) {
1276
+ const title = clean(it?.sTitle);
1277
+ const vm = title.match(/(\d+\.\d+)\s*版本限时频段\s*((?:([上下]期))?)/);
1278
+ if (!vm) continue;
1279
+ const ver = vm[1];
1280
+ const part = vm[2] || "";
1281
+ const text = clean(it.sIntro) + " " + clean(it.sContent);
1282
+ const 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})/g;
1283
+ const marks = [];
1284
+ let m;
1285
+ while ((m = re.exec(text)) !== null) {
1286
+ marks.push({ start: m[1], end: m[2], from: m.index, to: m.index + m[0].length, after: /版本更新后/.test(m[1]) });
1287
+ }
1288
+ const created = parseTime(it.dtCreateTime).ts ?? 0;
1289
+ for (let i = 0; i < marks.length; i++) {
1290
+ const seg = text.slice(marks[i].to, i + 1 < marks.length ? marks[i + 1].from : text.length);
1291
+ // 该时间窗对应的「限定S级代理人」句(排除独家重映/音擎回响的自选说明)
1292
+ const sentence = seg.split(/[。!;]/).find((s) => /限定S级代理人/.test(s) && !/重映|回响|可自选/.test(s));
1293
+ if (!sentence) continue;
1294
+ const roleM = sentence.match(/限定S级代理人\s*((?:[\[【][^\]】]+[\]】][、,,及和与\s]*)+)/);
1295
+ if (!roleM) continue;
1296
+ const roles = [...roleM[1].matchAll(/[\[【]([^\]】]+)[\]】]/g)].map((x) => normRole(x[1].trim())).join("、");
1297
+ if (!roles) continue;
1298
+ const sp = marks[i].after ? (verStart[ver] || { ts: null, text: null }) : parseTime(marks[i].start);
1299
+ const ep = parseTime(marks[i].end);
1300
+ if (ep.ts == null) continue;
1301
+ pools.push({
1302
+ ver, part, roles,
1303
+ startTs: sp.ts, endTs: ep.ts,
1304
+ startText: sp.text, endText: ep.text,
1305
+ raw: `${marks[i].after ? `${ver}版本更新后` : sp.text} ~ ${ep.text}`,
1306
+ created,
1307
+ isMain: true
1308
+ });
1309
+ }
1310
+ }
1311
+ if (pools.length === 0) return null;
1312
+ const cover = pools.filter((p) => p.startTs != null && p.startTs <= now && p.endTs >= now);
1313
+ // 起点未知(同版本更新公告未收录)但结束在未来 → 仍作为当期(与 selectCurrent 的宽松分支一致)
1314
+ const loose = pools.filter((p) => p.startTs == null && p.endTs >= now);
1315
+ const picked = (cover.length > 0 ? cover : loose).sort((a, b) => (b.created - a.created) || (a.endTs - b.endTs));
1316
+ if (picked.length === 0) return null;
1317
+ const first = picked[0];
1318
+ const same = picked.filter((p) => p.ver === first.ver && p.part === first.part);
1319
+ const roles = [...new Set(same.flatMap((p) => p.roles.split("、")).filter(Boolean))].join("、");
1320
+ // 卡池名:优先用更新公告里的「频段名」(当期名单命中的新代理人);复刻期无名时退回版本期名称
1321
+ let poolName = "";
1322
+ for (const r of roles.split("、")) {
1323
+ const base = r.replace(/[((].*$/, "").trim();
1324
+ if (base && poolOf[base]) { poolName = poolOf[base]; break; }
1325
+ }
1326
+ return {
1327
+ banner: poolName ? `「${poolName}」频段` : `${first.ver}版本限时频段${first.part}`,
1328
+ roles,
1329
+ bannerDates: first.startText && first.endText ? `${first.startText} ~ ${first.endText}` : first.raw,
1330
+ bannerDatesRaw: first.raw,
1331
+ startTs: first.startTs,
1332
+ endTs: first.endTs
1333
+ };
1334
+ }
1335
+
1336
+ // 绝区零卡池默认抓取器:官网公告优先;无当期频段公告 / 抓取失败 → 自动回退 Bwiki 往期调频
1337
+ async function fetchZzzGacha(listUrl, signal, now = nowMs()) {
1338
+ try {
1339
+ const payload = await proxyFetchJson(listUrl, "https://zzz.mihoyo.com/");
1340
+ const d = parseZzzFreq(payload, now);
1341
+ if (d) return d;
1342
+ } catch { /* 官方失败 → Bwiki 备选 */ }
1343
+ const apiUrl = ZZZ_BWIKI_URL + (ZZZ_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
1344
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
1345
+ if (!res.ok) throw new Error("http-" + res.status);
1346
+ const json = await res.json();
1347
+ const html = json?.parse?.text;
1348
+ if (typeof html !== "string") throw new Error("bad-json");
1349
+ return selectCurrent(parseAllBwiki(html), now);
1350
+ }
1351
+
1352
+ // 鸣潮:活动日历页 → font-size:17px 标题 + font-size:11px 时间,选当期
1353
+ // 注意:复用 selectCurrent 需要 isMain 字段(该函数按 isMain 过滤主池)
1354
+ function parseWuwaCalendar(html) {
1355
+ const items = [];
1356
+ const re = /font-size:17px[^>]*>\s*<p>\s*([\s\S]*?)\s*<\/p>([\s\S]*?)(?=font-size:17px|$)/g;
1357
+ let m;
1358
+ while ((m = re.exec(html)) !== null) {
1359
+ const name = stripTags(m[1]);
1360
+ if (!name) continue;
1361
+ const timeM = m[2].match(/font-size:11px[^>]*>\s*<p>\s*([\s\S]*?)\s*<\/p>/);
1362
+ const timeText = timeM ? stripTags(timeM[1]) : "";
1363
+ if (!/20\d{2}\//.test(timeText)) continue;
1364
+ const range = parseRange(timeText);
1365
+ items.push({ banner: name, name, ...range, isMain: true });
1366
+ }
1367
+ const now = nowMs();
1368
+ const active = sortEventItems(items
1369
+ .filter((it) => it.endTs != null && it.endTs >= now && (it.startTs == null || it.startTs <= now))
1370
+ .map((it) => ({ name: it.name || it.banner, startTs: it.startTs, endTs: it.endTs, raw: it.rawOriginal || it.raw })));
1371
+ if (active.length === 0) return null;
1372
+ // 外显:类别优先(战斗/高难类优先),同级内结束时间升序(③);悬停仍按 endTs 升序全量
1373
+ const primary = pickEventPrimary(active) || active[0];
1374
+ const dates = primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || "");
1375
+ return { banner: primary.name, roles: "", bannerDates: dates, bannerDatesRaw: primary.raw || dates, eventHover: buildEventHover(active) };
1376
+ }
1377
+
1378
+ // 终末地(wiki.gg):Headhunting/Banners 页 Current 分节 → 当期卡池
1379
+ // 该源经 host 代理抓取(fetchEndfieldWikiGg → proxyFetchText 设 Referer=wiki.gg origin),
1380
+ // 满足 Wiki.gg 的 Referer 校验,不会触发 403;本解析器仅处理代理返回的 HTML。
1381
+ function parseEndfieldCurrent(html) {
1382
+ const i = html.indexOf('id="Current"');
1383
+ if (i < 0) return null;
1384
+ const seg = html.slice(i);
1385
+ const tableEnd = seg.indexOf("</table>");
1386
+ const table = tableEnd >= 0 ? seg.slice(0, tableEnd) : seg;
1387
+ const nameM = table.match(/class="header"[^>]*>([^<]+)</);
1388
+ const asiaM = table.match(/Asia:<\/b>([\s\S]*?)<\/span>/);
1389
+ const upM = [...table.matchAll(/<li>[\s\S]*?title="([^"]+)"[\s\S]*?\(Drop Rate-UP\)/g)];
1390
+ const banner = nameM ? nameM[1].trim() : "";
1391
+ let startTs = null, endTs = null, startText = null, endText = null;
1392
+ if (asiaM) {
1393
+ const parts = stripTags(asiaM[1]).split(/[–-]/).map((x) => x.trim());
1394
+ if (parts.length >= 2) {
1395
+ const a = parseTime(parts[0]);
1396
+ const b = parseTime(parts[1]);
1397
+ if (a) { startTs = a.ts; startText = a.text; }
1398
+ if (b) { endTs = b.ts; endText = b.text; }
1399
+ }
1400
+ }
1401
+ // 不依赖 isMain 过滤的当期选择:直接按覆盖 now 判断
1402
+ if (!(startTs != null && endTs != null && startTs <= nowMs() && endTs >= nowMs())) return null;
1403
+ return {
1404
+ banner,
1405
+ roles: [...new Set(upM.map((m) => m[1]))].join("、"),
1406
+ bannerDates: startText && endText ? `${startText} ~ ${endText}` : ""
1407
+ };
1408
+ }
1409
+
1410
+ // 终末地(GachaTracker):banners 表格 → 当期卡池(卡池名/干员/起止)
1411
+ // GachaTracker 提供 CORS=[*],浏览器端可直接抓取(替代被 Referer 反爬拦截的 wiki.gg)
1412
+ function parseGachaTracker(html) {
1413
+ const rows = [...html.matchAll(/<tr id="([^"]+)">([\s\S]*?)<\/tr>/g)];
1414
+ const items = [];
1415
+ for (const rm of rows) {
1416
+ const body = rm[2];
1417
+ if (!body.includes("date-cell")) continue;
1418
+ const nameM = body.match(/banner-name-cell">[\s\S]*?<a[^>]*>([^<]+)<\/a>/);
1419
+ const dateM = [...body.matchAll(/date-cell">([\d-]+)<\/td>/g)];
1420
+ const charM = [...body.matchAll(/\/games\/endfield\/characters\/[^"]+" title="([^"]+)"/g)];
1421
+ if (!nameM || dateM.length < 2) continue;
1422
+ const start = dateM[0][1];
1423
+ const end = dateM[1][1];
1424
+ items.push({
1425
+ banner: stripTags(nameM[1]),
1426
+ roles: [...new Set(charM.map((m) => m[1]))].join("、"),
1427
+ startTs: new Date(start + "T00:00:00+08:00").getTime(),
1428
+ endTs: new Date(end + "T23:59:59+08:00").getTime()
1429
+ });
1430
+ }
1431
+ const now = nowMs();
1432
+ const cur = items.find((it) => it.startTs <= now && it.endTs >= now) || null;
1433
+ if (!cur) return null;
1434
+ const fmt = (ts) => {
1435
+ const d = new Date(ts);
1436
+ 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")}`;
1437
+ };
1438
+ return {
1439
+ banner: cur.banner,
1440
+ roles: cur.roles,
1441
+ bannerDates: `${fmt(cur.startTs)} ~ ${fmt(cur.endTs)}`
1442
+ };
1443
+ }
1444
+
1445
+ // 通用:从 Next.js flight payload HTML 中定位指定组件引用的 JS chunk URL 列表。
1446
+ // 适用于"HTML 无数据、数据编译在组件 chunk 里"的 Next.js 站点(如 canmoe)。
1447
+ // componentName 形如 "BannerCalendar";baseUrl 用于把相对路径补全为绝对 URL。
1448
+ function nextJsChunkUrls(html, componentName, baseUrl) {
1449
+ const i = html.indexOf(componentName);
1450
+ if (i < 0) return [];
1451
+ const seg = html.slice(Math.max(0, i - 1500), i);
1452
+ const br = seg.lastIndexOf("[");
1453
+ if (br < 0) return [];
1454
+ // flight payload 内 chunk 路径是双重转义(\\\"),还原一层后提取
1455
+ const raw = seg.slice(br).replace(/\\\\"/g, '"').replace(/\\"/g, '"');
1456
+ const names = [...raw.matchAll(/\/_next\/static\/chunks\/([A-Za-z0-9_.~-]+\.js)/g)].map((x) => x[1]);
1457
+ // 补全为绝对 URL:优先页面 origin(chunk 路径是站内相对路径)
1458
+ let origin = "";
1459
+ try { origin = new URL(baseUrl || "").origin; } catch { /* 无 baseUrl 时保持相对 */ }
1460
+ return [...new Set(names)].map((n) => origin + "/_next/static/chunks/" + n);
1461
+ }
1462
+
1463
+ // 从 canmoe chunk JS 提取当期卡池:
1464
+ // 当期数据形如 d={梨诺:{windows:[{start,end,version,period,isRerun}]}},u=[...]
1465
+ // 历史数据形如 p=[{id,title,subtitle,version,periodStart,periodEnd,featured:[...]}]
1466
+ // 返回 { banner, roles, bannerDates };无法识别返回 null。
1467
+ // 从 canmoe chunk JS 提取当期卡池:只采用"时间窗口覆盖当前时刻"的条目。
1468
+ // 当期在 d={角色:{windows:[{start,end,version,period,isRerun}]}};过期/下一期在 p=[{title,subtitle,version,periodStart,periodEnd,featured}]。
1469
+ function currentFromCanmoe(js, now) {
1470
+ now = now || nowMs();
1471
+ const fmt = (iso) => {
1472
+ const d = new Date(iso);
1473
+ 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")}`;
1474
+ };
1475
+ // 1) 当期 d:windows 覆盖当前 → 直接用
1476
+ const curM = js.match(/\bd=\{(.+?)\},\s*u=\[/);
1477
+ if (curM) {
1478
+ const inner = curM[1];
1479
+ const featuredM = inner.match(/([^:{}]+):\{windows:/);
1480
+ const roles = featuredM ? featuredM[1].trim() : "";
1481
+ for (const w of inner.matchAll(/windows\s*:\s*\[\s*\{\s*start\s*:\s*"([^"]+)"\s*,\s*end\s*:\s*"([^"]+)"\s*,\s*version\s*:\s*"([^"]+)"\s*,\s*period\s*:\s*(\d+)\s*,\s*isRerun\s*:\s*(!0|!1|true|false)\s*\}\s*\]/g)) {
1482
+ const a = new Date(w[1]).getTime(), b = new Date(w[2]).getTime();
1483
+ if (a <= now && now <= b) return { banner: `\u3010${w[3]}\u3011${roles}`, roles, bannerDates: `${fmt(w[1])} ~ ${fmt(w[2])}` };
1484
+ }
1485
+ }
1486
+ // 2) p 数组中的"当前进行中"条目(过期当期后以此为兜底)
1487
+ const arr = extractCanmoeP(js);
1488
+ if (arr) {
1489
+ for (const e of arr) {
1490
+ const ps = (e.match(/periodStart\s*:\s*"([^"]*)"/) || [])[1];
1491
+ const pe = (e.match(/periodEnd\s*:\s*"([^"]*)"/) || [])[1];
1492
+ if (!ps || !pe) continue;
1493
+ const a = new Date(ps).getTime(), b = new Date(pe).getTime();
1494
+ if (a <= now && now <= b) {
1495
+ const title = (e.match(/title\s*:\s*"([^"]*)"/) || [])[1] || "";
1496
+ const subtitle = (e.match(/subtitle\s*:\s*"([^"]*)"/) || [])[1] || "";
1497
+ const version = (e.match(/version\s*:\s*"([^"]*)"/) || [])[1] || "";
1498
+ const roles = subtitle || title;
1499
+ return { banner: title || `\u3010${version}\u3011${subtitle}`, roles, bannerDates: `${fmt(ps)} ~ ${fmt(pe)}`, bannerDatesRaw: `${fmt(ps)} ~ ${fmt(pe)}` };
1500
+ }
1501
+ }
1502
+ }
1503
+ return null;
1504
+ }
1505
+
1506
+ // 提取 canmoe chunk 中 p=[...] 数组(含 periodStart 的那个,平衡括号解析),返回元素子串数组
1507
+ function extractCanmoeP(js) {
1508
+ for (let s = js.indexOf("p=["); s >= 0; s = js.indexOf("p=[", s + 1)) {
1509
+ const start = js.indexOf("[", s);
1510
+ let depth = 0, inStr = false, q = "", end = -1;
1511
+ for (let i = start; i < js.length; i++) {
1512
+ const ch = js[i];
1513
+ if (inStr) { if (ch === "\\") i++; else if (ch === q) inStr = false; continue; }
1514
+ if (ch === '"' || ch === "'") { inStr = true; q = ch; continue; }
1515
+ if (ch === "[") depth++;
1516
+ else if (ch === "]") { depth--; if (depth === 0) { end = i; break; } }
1517
+ }
1518
+ if (end < 0) continue;
1519
+ const body = js.slice(start + 1, end);
1520
+ if (!/periodStart/.test(body)) continue;
1521
+ const out = [];
1522
+ let d2 = 0, s2 = false, q2 = "", st = 0;
1523
+ for (let k = 0; k < body.length; k++) {
1524
+ const ch = body[k];
1525
+ if (s2) { if (ch === "\\") k++; else if (ch === q2) s2 = false; continue; }
1526
+ if (ch === '"' || ch === "'") { s2 = true; q2 = ch; continue; }
1527
+ if (ch === "{" || ch === "[") d2++;
1528
+ else if (ch === "}" || ch === "]") d2--;
1529
+ else if (ch === "," && d2 === 0) { out.push(body.slice(st, k)); st = k + 1; }
1530
+ }
1531
+ out.push(body.slice(st));
1532
+ return out;
1533
+ }
1534
+ return null;
1535
+ }
1536
+
1537
+ // 兼容旧调用:parseCanmoe / parseCanmoeLoose 均走统一的"当期选择"逻辑(now 可注入)
1538
+ function parseCanmoe(js, now = nowMs()) { return currentFromCanmoe(js, now); }
1539
+ function parseCanmoeLoose(js, now = nowMs()) { return currentFromCanmoe(js, now); }
1540
+
1541
+ // 终末地(canmoe 经 host 代理):页面 HTML → 定位 BannerCalendar chunk → 抓 chunk JS → 窗口匹配当期
1542
+ // canmoe 无 CORS 头,两步都经 host 代理(referer 用页面 origin 满足反爬)
1543
+ // 数据在某一组件的 chunk 里(含当期 d={...} 与历史 p=[...],p 内含多期 periodStart/End),
1544
+ // currentFromCanmoe(js, now) 按 now 在 d/p 的多期窗口里做匹配,now 可注入以回放其他时刻
1545
+ async function fetchCanmoeEndfield(pageUrl, now = nowMs()) {
1546
+ const html = await proxyFetchText(pageUrl, "https://end.canmoe.com/");
1547
+ const chunks = nextJsChunkUrls(html, "BannerCalendar", pageUrl);
1548
+ if (chunks.length === 0) return null;
1549
+ for (const c of chunks) {
1550
+ try {
1551
+ const js = await proxyFetchText(c, "https://end.canmoe.com/");
1552
+ const d = parseCanmoe(js, now) || parseCanmoeLoose(js, now);
1553
+ if (d) {
1554
+ const hover = canmoePoolHover(js, now);
1555
+ if (hover) d.bannerHover = hover;
1556
+ return d;
1557
+ }
1558
+ } catch { /* 下一个 chunk */ }
1559
+ }
1560
+ return null;
1561
+ }
1562
+
1563
+ // 终末地卡池列悬停:canmoe 卡池日历 chunk 内同期全部卡池条目(特许寻访 / 重构寻访 等),
1564
+ // 每池"卡池名:角色"一行 + 时间;窗口相同则合并时间;结束时间升序(0/1 池返回 "" 走单条兜底)
1565
+ function canmoePoolHover(js, now) {
1566
+ const arr = extractCanmoeP(js);
1567
+ if (!arr) return "";
1568
+ const pools = [];
1569
+ for (const e of arr) {
1570
+ const ps = (e.match(/periodStart\s*:\s*"([^"]*)"/) || [])[1];
1571
+ const pe = (e.match(/periodEnd\s*:\s*"([^"]*)"/) || [])[1];
1572
+ if (!ps || !pe) continue;
1573
+ const a = new Date(ps).getTime(), b = new Date(pe).getTime();
1574
+ if (!(a <= now && now <= b)) continue;
1575
+ const title = (e.match(/title\s*:\s*"([^"]*)"/) || [])[1] || "";
1576
+ const subtitle = (e.match(/subtitle\s*:\s*"([^"]*)"/) || [])[1] || "";
1577
+ if (!title && !subtitle) continue;
1578
+ pools.push({
1579
+ name: title || subtitle,
1580
+ label: title && subtitle ? `${title}\uFF1A${subtitle}` : (title || subtitle),
1581
+ startTs: a,
1582
+ endTs: b
1583
+ });
1584
+ }
1585
+ return buildPoolHover(pools);
1586
+ }
1587
+
1588
+ // 异环(ldshop 繁体):解析「項目/資訊」卡池表(含 期間/角色/棋盤 行),返回全部卡池
1589
+ // 表格行结构:<td><p>期間</p></td><td><p>8月19日-9月9日</p></td>
1590
+ function parseLdshopPools(html) {
1591
+ const out = [];
1592
+ const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/gi)];
1593
+ for (const tb of tables) {
1594
+ const body = tb[1];
1595
+ if (!/期間/.test(body.replace(/<[^>]+>/g, "|"))) continue;
1596
+ let info = {};
1597
+ for (const rm of body.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)) {
1598
+ const tds = [...rm[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((m) => stripTags(m[1]).trim());
1599
+ if (tds.length >= 2 && tds[0] && tds[0] !== "項目") info[tds[0]] = tds[1];
1600
+ }
1601
+ if (!info["期間"]) continue;
1602
+ const range = parseLdshopRange(info["期間"]);
1603
+ if (!range) continue;
1604
+ const chars = [info["全新S級角色"], info["復刻S級角色"]].filter(Boolean).join("/");
1605
+ out.push({
1606
+ banner: info["角色棋盤"] || "异环卡池",
1607
+ roles: chars,
1608
+ ...range,
1609
+ isMain: true
1610
+ });
1611
+ }
1612
+ return out;
1613
+ }
1614
+
1615
+ // 中文明期间 → startTs/endTs/bannerDates(如 "8月19日-9月9日";跨年自动+1年)
1616
+ function parseLdshopRange(raw, now) {
1617
+ const s = String(raw).trim();
1618
+ const m = s.match(/(\d{1,2})月(\d{1,2})日\s*[-\-]\s*(\d{1,2})月(\d{1,2})日/);
1619
+ if (!m) return null;
1620
+ const base = now || new Date();
1621
+ const y = base.getFullYear();
1622
+ const a = new Date(y, Number(m[1]) - 1, Number(m[2]), 0, 0);
1623
+ let b = new Date(y, Number(m[3]) - 1, Number(m[4]), 23, 59);
1624
+ if (b < a) b = new Date(y + 1, Number(m[3]) - 1, Number(m[4]), 23, 59);
1625
+ const fmt = (d) => `${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")}`;
1626
+ return { startTs: a.getTime(), endTs: b.getTime(), startText: fmt(a), endText: fmt(b), raw: `${fmt(a)} ~ ${fmt(b)}` };
1627
+ }
1628
+
1629
+ // 异环(ldshop 经 host 代理):抓页面 → 解析卡池表 → 选当期
1630
+ async function fetchLdshopNte(pageUrl) {
1631
+ const html = await proxyFetchText(pageUrl, "https://www.ldshop.gg/");
1632
+ const pools = parseLdshopPools(html);
1633
+ if (pools.length === 0) return null;
1634
+ return selectCurrent(pools, nowMs());
1635
+ }
1636
+
1637
+ // 异环(官网 yh.wanmei.com 公告,经 host 代理):抓游戏公告列表 → 取最新维护/更新公告 → 解析当期限定棋盘卡池与限时活动
1638
+ async function fetchNteWanmei(listUrl, signal) {
1639
+ const ref = "https://yh.wanmei.com/";
1640
+ const list = await proxyFetchText(listUrl, ref);
1641
+ // 定位最新"维护/更新"公告链接:优先"停服维护/版本更新"(带新卡池),排除"不停服"更新(无新卡池)
1642
+ const links = [...list.matchAll(/<a href="(\/news\/gamebroad\/\d+\/\d+\.html)"[\s\S]*?<h2 class="title">([^<]+)<\/h2>/g)];
1643
+ const maint = links.find((x) => /停服维护|停服更新|版本更新/.test(x[2]) && !/不停服/.test(x[2]))
1644
+ || links.find((x) => !/不停服/.test(x[2]))
1645
+ || links[0];
1646
+ if (!maint) return null;
1647
+ const detail = await proxyFetchText("https://yh.wanmei.com" + maint[1], ref);
1648
+ return parseNteWanmei(detail);
1649
+ }
1650
+
1651
+ // 解析官网公告正文 → 当期卡池(全新限定S级角色所属限定棋盘)+ 当期活动(限时活动)
1652
+ function parseNteWanmei(html) {
1653
+ const text = String(html || "")
1654
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
1655
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
1656
+ .replace(/<[^>]+>/g, "\n")
1657
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
1658
+ .replace(/\n\s*\n+/g, "\n").trim();
1659
+ const fmt = (mo, d, h, mi) => `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
1660
+ const data = { banner: "", roles: "", bannerDates: "", bannerDatesRaw: "", event: "", eventDates: "", eventDatesRaw: "" };
1661
+ // 当期卡池:全新限定S级角色「X」→「Y」限定棋盘, 开放时间 M月D日维护更新后-M月D日05:59
1662
+ const newRole = text.match(/全新限定S级角色「([^」]+)」[\s\S]{0,400}?可通过「([^」]+)」限定棋盘获得[\s\S]{0,300}?开放时间:(\d+)月(\d+)日维护更新后-(\d+)月(\d+)日05:59/);
1663
+ if (newRole) {
1664
+ const mo = +newRole[3], d = +newRole[4], emo = +newRole[5], ed = +newRole[6];
1665
+ data.banner = `「${newRole[2]}」限定棋盘`;
1666
+ data.roles = newRole[1];
1667
+ data.bannerDates = `${fmt(mo, d, 11, 0)} ~ ${fmt(emo, ed, 5, 59)}`;
1668
+ data.bannerDatesRaw = data.bannerDates;
1669
+ }
1670
+ // 当期活动:「X」限时活动 活动时间:M月D日(维护更新后|hh:mm)-M月D日hh:mm
1671
+ const ev = text.match(/「([^」]+)」限时活动[\s\S]{0,200}?活动时间:(\d+)月(\d+)日(?:维护更新后|(\d{2}):(\d{2}))-(\d+)月(\d+)日(\d{2}):(\d{2})/);
1672
+ if (ev) {
1673
+ const sMo = +ev[2], sD = +ev[3], sH = ev[4] ? +ev[4] : 11, sMi = ev[5] ? +ev[5] : 0;
1674
+ const eMo = +ev[6], eD = +ev[7], eH = +ev[8], eMi = +ev[9];
1675
+ data.event = ev[1];
1676
+ data.eventDates = `${fmt(sMo, sD, sH, sMi)} ~ ${fmt(eMo, eD, eH, eMi)}`;
1677
+ data.eventDatesRaw = data.eventDates;
1678
+ }
1679
+ if (!data.banner) return null;
1680
+ return data;
1681
+ }
1682
+
1683
+ // 通用抓取网页文本:MediaWiki api.php(action=parse)→ JSON 的 parse.text;其它 URL → 原始 HTML
1684
+ async function fetchHtmlText(url, signal) {
1685
+ const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
1686
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
1687
+ if (!res.ok) throw new Error("http-" + res.status);
1688
+ if (/action\s*=\s*parse/i.test(url)) {
1689
+ const json = await res.json();
1690
+ const text = json?.parse?.text;
1691
+ if (typeof text !== "string") throw new Error("bad-json");
1692
+ return text;
1693
+ }
1694
+ return res.text();
1695
+ }
1696
+
1697
+ // 通用卡池解析(新增自定义条目的"卡池来源"地址用):
1698
+ // 依次尝试 bwiki 式「时间+版本」表、方舟式「限时寻访」表、GachaTracker 式日期表、
1699
+ // Next.js SPA(canmoe 等,页面无表格、数据在组件 chunk 里),选当期;
1700
+ // 全部失败返回 null(调用方按解析失败处理,不做可达性健康检查)
1701
+ async function tryParseGenericGacha(url, signal) {
1702
+ const html = await fetchHtmlText(url, signal);
1703
+ const now = nowMs();
1704
+ const cur = selectCurrent(parseAllBwiki(html), now) || selectCurrent(parseArknights(html), now);
1705
+ if (cur && cur.banner && cur.bannerDates) return cur;
1706
+ const gt = parseGachaTracker(html);
1707
+ if (gt && gt.banner && gt.bannerDates) return gt;
1708
+ // Next.js SPA:HTML 无表格数据,定位组件 chunk 后抓 chunk JS 解析。
1709
+ // chunk 与页面同源:页面能直连(CORS 允许)时 chunk 直连,否则经 host 代理。
1710
+ const chunks = nextJsChunkUrls(html, "BannerCalendar", url);
1711
+ for (const c of chunks) {
1712
+ try {
1713
+ const js = await fetchHtmlText(c, signal);
1714
+ const d = parseCanmoe(js) || parseCanmoeLoose(js);
1715
+ if (d && d.banner && d.bannerDates) return d;
1716
+ } catch { /* 下一个 chunk */ }
1717
+ }
1718
+ return null;
1719
+ }
1720
+
1721
+ // 通用活动解析:扫描含「时间」(或「活动时间」)表头的表格,行内找时间与名称列,选当期
1722
+ // 起始为"版本更新后"等无日期文本时保留 startTs=null,由 selectCurrent 的 fillMissingStarts 补全
1723
+ function collectGenericEvents(html) {
1724
+ const items = [];
1725
+ const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
1726
+ for (const t of tables) {
1727
+ const body = t[1];
1728
+ if (!/<th[^>]*>[^<]*时间[^<]*<\/th>/i.test(body)) continue;
1729
+ // 「类型」列(如 版本活动/常规活动/剧情活动)→ 用于活动外显的类别优先级
1730
+ const heads = [...body.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/g)].map((m) => stripTags(m[1]).trim());
1731
+ const catIdx = heads.findIndex((h) => /类型|類型/.test(h));
1732
+ for (const rm of body.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)) {
1733
+ const row = rm[1];
1734
+ if (!/<td/i.test(row)) continue;
1735
+ const tds = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => stripTags(m[1]).trim());
1736
+ if (tds.length < 2) continue;
1737
+ // 时间列(含日期/区间)与名称列分开
1738
+ let name = "", time = "";
1739
+ for (const td of tds) {
1740
+ if (!time && /20\d{2}[\/-]\d{1,2}[\/-]\d{1,2}|[~~]/.test(td)) time = td;
1741
+ else if (td && !name) name = td;
1742
+ }
1743
+ if (!time || !name) continue;
1744
+ // 名字里偶发混进图片文件名残渣(如「文件:巡星之礼第二十六期.png 」)→ 去掉该前缀
1745
+ name = name.replace(/^文件:[^\s]*?\.(?:png|jpe?g|gif|webp|svg)\s*/i, "").trim();
1746
+ if (!name) continue;
1747
+ const range = parseRange(time);
1748
+ // 起始可为 null("版本更新后"),结束时间必须有效
1749
+ if (range.endTs == null) continue;
1750
+ items.push({ banner: name, cat: catIdx >= 0 ? (tds[catIdx] || "") : "", ...range, isMain: true });
1751
+ }
1752
+ }
1753
+ return items;
1754
+ }
1755
+
1756
+ // 当期活动(外显取 selectCurrent 的那条,行为同旧版)
1757
+ function parseGenericEvents(html) {
1758
+ return selectCurrent(collectGenericEvents(html), nowMs());
1759
+ }
1760
+
1761
+ // Bwiki 卡池列载荷(原神/星铁):外显沿用 selectCurrent(同窗口主池角色合并、武器/光锥池不入选);
1762
+ // bannerHover 列出同期全部主池(每池"池名:角色"+时间;窗口相同则合并时间;结束时间升序)
1763
+ function bwikiGachaPayload(html) {
1764
+ const items = parseAllBwiki(html);
1765
+ // selectCurrent 会就地补全缺失起点(fillMissingStarts),故先取快照
1766
+ const snapshot = items.map((it) => ({ banner: it.banner, roles: it.roles, startTs: it.startTs, endTs: it.endTs, raw: it.rawOriginal || it.raw, isMain: it.isMain }));
1767
+ const cur = selectCurrent(items, nowMs());
1768
+ if (!cur) return null;
1769
+ const now = nowMs();
1770
+ const pools = snapshot
1771
+ .filter((it) => it.isMain && it.endTs != null && it.endTs >= now && (it.startTs == null || it.startTs <= now))
1772
+ .map((it) => ({
1773
+ name: it.banner,
1774
+ label: `${it.banner}${it.roles ? `\uFF1A${cleanRoles(it.roles)}` : ""}`,
1775
+ startTs: it.startTs,
1776
+ endTs: it.endTs,
1777
+ raw: it.raw
1778
+ }));
1779
+ return { ...cur, bannerHover: buildPoolHover(pools) };
1780
+ }
1781
+
1782
+ // 活动列载荷:外显=排序第一条(最快结束的当期活动,③);eventHover=全部覆盖当前时刻的活动(同序)。
1783
+ // 注意 selectCurrent 会就地补全缺失起点(fillMissingStarts),故这里只取快照自行排序,
1784
+ // 避免"版本更新后 ~ 未来"这类起点未给的行被补成未来起点而漏掉。
1785
+ // 只有 1 条时 buildEventHover 返回 "",由 UI 退回单条展示(兜底)。
1786
+ function genericEventPayload(html) {
1787
+ const items = collectGenericEvents(html);
1788
+ const snapshot = items.map((it) => ({ name: it.banner, cat: it.cat || "", startTs: it.startTs, endTs: it.endTs, raw: it.rawOriginal || it.raw }));
1789
+ const now = nowMs();
1790
+ const active = sortEventItems(snapshot.filter((it) => it.endTs != null && it.endTs >= now && (it.startTs == null || it.startTs <= now)));
1791
+ if (active.length === 0) return null;
1792
+ // 外显:类别优先(剧情/叙事、限时高难),同级内结束时间升序;悬停仍按 endTs 升序全量
1793
+ const primary = pickEventPrimary(active) || active[0];
1794
+ return {
1795
+ event: primary.name,
1796
+ eventDates: primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || ""),
1797
+ eventDatesRaw: primary.raw || "",
1798
+ eventHover: buildEventHover(active)
1799
+ };
1800
+ }
1801
+
1802
+ // 明日方舟(PRTS 活动一览):表格含「活动开始时间」列 + 隐藏 data-time="开始秒,结束秒"(Unix 秒)。
1803
+ // 开始时间用第一列文本("2026-08-22 04:00"),结束时间用 data-time 第二个值(UTC 秒 → +08)。
1804
+ // 注意 data-time 第一个值是页面缓存时刻(非开始时间),故开始以文本列为准。
1805
+ // 活动名带核心分类前缀("支线故事:墟·复刻"):分类取第三列 <a title="分类:XXX"> 链接,
1806
+ // 核心分类 = 排除"复刻活动"(修饰词)后的第一个。
1807
+ function collectPrtsEvents(html) {
1808
+ const items = [];
1809
+ const tables = [...html.matchAll(/<table[^>]*>([\s\S]*?)<\/table>/g)];
1810
+ for (const t of tables) {
1811
+ const body = t[1];
1812
+ if (!/<th[^>]*>[^<]*时间[^<]*<\/th>/i.test(body)) continue;
1813
+ for (const rm of body.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)) {
1814
+ const row = rm[1];
1815
+ if (!/data-time="(\d+),(\d+)"/.test(row)) continue;
1816
+ const tm = row.match(/data-time="(\d+),(\d+)"/);
1817
+ if (!tm) continue;
1818
+ const endTs = Number(tm[2]) * 1000; // 结束(UTC 秒 → ms)
1819
+ if (!endTs) continue;
1820
+ const tds = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1]);
1821
+ if (tds.length < 3) continue;
1822
+ // 名称:第二列第一个 <a> 的文本("墟·复刻"),避开状态徽章与 <script> 内容
1823
+ const aM = tds[1].match(/<a[^>]*>([\s\S]*?)<\/a>/);
1824
+ const name = aM ? stripTags(aM[1]).trim() : "";
1825
+ if (!name) continue;
1826
+ // 分类:第三列所有 <a title="分类:XXX">;核心分类排除"复刻活动"修饰后取第一个
1827
+ const catLinks = [...tds[2].matchAll(/title="分类:([^"]+)"/g)].map((m) => m[1]);
1828
+ let coreCat = "";
1829
+ if (catLinks.length > 0) {
1830
+ coreCat = catLinks.find((c) => c !== "\u590D\u523B\u6D3B\u52A8") || catLinks[0];
1831
+ } else {
1832
+ coreCat = stripTags(tds[2]).replace(/\s+/g, " ").trim();
1833
+ }
1834
+ const label = coreCat ? `${coreCat}\uFF1A${name}` : name;
1835
+ // 开始:第一列文本("2026-08-22 04:00")
1836
+ const st = parseTime(stripTags(tds[0]).trim());
1837
+ items.push({
1838
+ banner: label,
1839
+ cat: coreCat,
1840
+ ...range2(st, endTs),
1841
+ isMain: true
1842
+ });
1843
+ }
1844
+ }
1845
+ return items;
1846
+ }
1847
+
1848
+ // 当期活动(外显取 selectCurrent 的那条,行为同旧版)
1849
+ function parsePrtsEvents(html) {
1850
+ return selectCurrent(collectPrtsEvents(html), nowMs());
1851
+ }
1852
+
1853
+ // 活动列载荷:外显=排序第一条(最快结束的当期活动,③);eventHover=全部覆盖当前时刻的活动(同序)
1854
+ function prtsEventPayload(html) {
1855
+ const items = collectPrtsEvents(html);
1856
+ const snapshot = items.map((it) => ({ name: it.banner, cat: it.cat || "", startTs: it.startTs, endTs: it.endTs, raw: it.raw }));
1857
+ const now = nowMs();
1858
+ const active = sortEventItems(snapshot.filter((it) => it.endTs != null && it.endTs >= now && (it.startTs == null || it.startTs <= now)));
1859
+ if (active.length === 0) return null;
1860
+ // 外显:类别优先(支线故事/危机合约等 vs 登录活动),同级内结束时间升序
1861
+ const primary = pickEventPrimary(active) || active[0];
1862
+ return {
1863
+ event: primary.name,
1864
+ eventDates: primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || ""),
1865
+ eventDatesRaw: primary.raw || "",
1866
+ eventHover: buildEventHover(active)
1867
+ };
1868
+ }
1869
+ // PRTS 起止 → 统一文本(parsePrtsEvents 内部用)
1870
+ function range2(st, endTs) {
1871
+ const fmt = (ts) => {
1872
+ const d = new Date(ts);
1873
+ 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")}`;
1874
+ };
1875
+ const startTs = st && st.ts != null ? st.ts : null;
1876
+ return {
1877
+ startTs,
1878
+ endTs,
1879
+ startText: st && st.text ? st.text : "",
1880
+ endText: fmt(endTs),
1881
+ raw: `${st && st.text ? st.text : ""} ~ ${fmt(endTs)}`.trim()
1882
+ };
1883
+ }
1884
+
1885
+ // 终末地(Game8 英文站):活动排期表,条目形如
1886
+ // <a class="a-link" href="...">Bedazzling Dawnstar Sign-In</a><br>(Version 1.4)<br>08/09/26 - 09/02/26
1887
+ // 日期为美式 MM/DD/YY;多个并行当期活动时选"结束最晚"(覆盖全部当期窗口)。
1888
+ // 无起止区间(只有开始日,如 "07/16")的条目跳过。
1889
+ function parseGame8Events(html) {
1890
+ const items = [];
1891
+ for (const m of html.matchAll(/<a class="a-link"[^>]*>([^<]+)<\/a><br>\((Version[^)]*)\)<br>([^<]*)/g)) {
1892
+ const period = m[3].trim();
1893
+ if (!/^\d{1,2}\/\d{1,2}\/\d{2}\s*-\s*\d{1,2}\/\d{1,2}\/\d{2}/.test(period)) continue;
1894
+ const mm = period.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2})\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{2})/);
1895
+ if (!mm) continue;
1896
+ const y = 2000 + Number(mm[3]);
1897
+ const a = new Date(y, Number(mm[1]) - 1, Number(mm[2]), 0, 0);
1898
+ let b = new Date(y, Number(mm[4]) - 1, Number(mm[5]), 23, 59);
1899
+ if (b < a) b = new Date(y + 1, Number(mm[4]) - 1, Number(mm[5]), 23, 59);
1900
+ items.push({
1901
+ banner: m[1].trim(),
1902
+ roles: "",
1903
+ startTs: a.getTime(),
1904
+ endTs: b.getTime(),
1905
+ isMain: true
1906
+ });
1907
+ }
1908
+ if (items.length === 0) return null;
1909
+ const now = nowMs();
1910
+ // 当期(进行中)选结束最晚;无当期时返回 null
1911
+ const cur = items
1912
+ .filter((it) => it.startTs <= now && it.endTs >= now)
1913
+ .sort((x, y) => y.endTs - x.endTs)[0];
1914
+ if (!cur) return null;
1915
+ const fmt = (ts) => {
1916
+ const d = new Date(ts);
1917
+ 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")}`;
1918
+ };
1919
+ return {
1920
+ banner: cur.banner,
1921
+ roles: "",
1922
+ bannerDates: `${fmt(cur.startTs)} ~ ${fmt(cur.endTs)}`,
1923
+ bannerDatesRaw: `${fmt(cur.startTs)} ~ ${fmt(cur.endTs)}`
1924
+ };
1925
+ }
1926
+
1927
+ // parseGame8Events 的宽松回退:容忍 <a> 属性顺序/空白变化。仅在主解析未命中时使用。
1928
+ function parseGame8EventsLoose(html) {
1929
+ const items = [];
1930
+ 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)) {
1931
+ const period = m[2].trim();
1932
+ const mm = period.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
1933
+ if (!mm) continue;
1934
+ const y = mm[3].length === 2 ? 2000 + Number(mm[3]) : Number(mm[3]);
1935
+ const a = new Date(y, Number(mm[1]) - 1, Number(mm[2]), 0, 0);
1936
+ let b = new Date(y, Number(mm[4]) - 1, Number(mm[5]), 23, 59);
1937
+ if (b < a) b = new Date(y + 1, Number(mm[4]) - 1, Number(mm[5]), 23, 59);
1938
+ items.push({ banner: m[1].trim(), roles: "", startTs: a.getTime(), endTs: b.getTime(), isMain: true });
1939
+ }
1940
+ if (items.length === 0) return null;
1941
+ const now = nowMs();
1942
+ const cur = items.filter((it) => it.startTs <= now && it.endTs >= now).sort((x, y) => y.endTs - x.endTs)[0];
1943
+ if (!cur) return null;
1944
+ const fmt = (ts) => {
1945
+ const d = new Date(ts);
1946
+ 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")}`;
1947
+ };
1948
+ return {
1949
+ banner: cur.banner,
1950
+ roles: "",
1951
+ bannerDates: `${fmt(cur.startTs)} ~ ${fmt(cur.endTs)}`,
1952
+ bannerDatesRaw: `${fmt(cur.startTs)} ~ ${fmt(cur.endTs)}`
1953
+ };
1954
+ }
1955
+
1956
+ // 终末地(Game8 经 host 代理,无 CORS):活动排期页
1957
+ async function fetchGame8Endfield(pageUrl) {
1958
+ const html = await proxyFetchText(pageUrl, "https://game8.co/");
1959
+ return parseGame8Events(html) || parseGame8EventsLoose(html);
1960
+ }
1961
+
1962
+ // 从 fz.wiki 页面(Next.js App Router)内联 RSC flight payload 中抽取 contentJson 的 JSON 字符串。
1963
+ // 数据被序列化为 self.__next_f.push([1,"..."]);拼接后按引号转义还原,再按大括号平衡取 contentJson 对象。
1964
+ function extractFzContentJson(html) {
1965
+ const re = /self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)/g;
1966
+ let full = "";
1967
+ let m;
1968
+ while ((m = re.exec(html))) {
1969
+ try { full += JSON.parse('"' + m[1] + '"'); } catch { full += m[1]; }
1970
+ }
1971
+ if (!full.includes('"contentJson"')) return null;
1972
+ const ci = full.indexOf('"contentJson"');
1973
+ let start = full.indexOf("{", ci);
1974
+ if (start < 0) return null;
1975
+ let depth = 0, i = start, inStr = false, esc = false;
1976
+ for (; i < full.length; i++) {
1977
+ const c = full[i];
1978
+ if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; }
1979
+ if (c === '"') { inStr = true; continue; }
1980
+ if (c === "{") depth++;
1981
+ else if (c === "}") { depth--; if (depth === 0) break; }
1982
+ }
1983
+ try { return JSON.parse(full.slice(start, i + 1)); } catch { return null; }
1984
+ }
1985
+
1986
+ // 终末地(FZ Wiki /wiki/活动):页面 RSC payload → 覆盖当前时刻的活动列表。
1987
+ // 活动节点有三种历史结构,全部兼容:
1988
+ // ① endfieldCardActivityIndex.attrs.activities[](最早)
1989
+ // ② 独立子节点 type=*endfieldCardActivityIndex__activities(字段在自身 attrs)
1990
+ // ③ endfieldCardActivityIndex.content[] → wikiCardItem.attrs.data(当前线上结构)
1991
+ // 只收"有明确起止"的活动(timeRanges 末段的 open+close 都非空),
1992
+ // 与旧行为一致——无 close 的是新手/每周/引导等常驻活动,不当作当期活动。
1993
+ // 时间格式 "2026/9/2 7:00:00";外显=排序第一条(结束最早的),悬停按同序逐行。
1994
+ function parseFzWikiActivities(html, now) {
1995
+ const obj = extractFzContentJson(html);
1996
+ if (!obj) return null;
1997
+ const acts = [];
1998
+ const pushAct = (name, tags, trs) => {
1999
+ if (typeof name !== "string" || !name) return;
2000
+ if (!Array.isArray(trs) || trs.length === 0) return;
2001
+ const tr = trs[trs.length - 1];
2002
+ if (!tr || !tr.open || !tr.close) return;
2003
+ acts.push({ name, tags: tags || [], open: tr.open, close: tr.close });
2004
+ };
2005
+ (function walk(n) {
2006
+ if (!n || typeof n !== "object") return;
2007
+ if (Array.isArray(n)) { n.forEach(walk); return; }
2008
+ // ① 旧结构:活动在父节点 attrs.activities
2009
+ if (n.type === "endfieldCardActivityIndex" && Array.isArray(n.attrs?.activities)) {
2010
+ for (const a of n.attrs.activities) pushAct(a.name, a.tags, a.timeRanges);
2011
+ }
2012
+ // ② 旧结构:独立的 __activities 子节点,字段在各自 attrs 上
2013
+ if (n.attrs && typeof n.type === "string" && n.type.includes("endfieldCardActivityIndex__activities")) {
2014
+ pushAct(n.attrs.name, n.attrs.tags, n.attrs.timeRanges);
2015
+ }
2016
+ // ③ 当前结构:endfieldCardActivityIndex.content[] → wikiCardItem.attrs.data
2017
+ if (n.type === "endfieldCardActivityIndex" && Array.isArray(n.content)) {
2018
+ for (const c of n.content) {
2019
+ const d = c && c.attrs && c.attrs.data;
2020
+ if (d) pushAct(d.name, d.tags, d.timeRanges);
2021
+ }
2022
+ }
2023
+ for (const k of Object.keys(n)) walk(n[k]);
2024
+ })(obj);
2025
+ if (acts.length === 0) return null;
2026
+ const parseT = (s) => new Date(String(s).replace(/\//g, "-")).getTime();
2027
+ const t0 = now || nowMs();
2028
+ // 覆盖当前时刻的活动统一排序(③ 结束时间升序)供悬停;外显另按类别优先挑选
2029
+ const activeActs = sortEventItems(acts
2030
+ .filter((a) => parseT(a.open) <= t0 && parseT(a.close) >= t0)
2031
+ .map((a) => ({ name: a.name, tags: (a.tags || []).join("/"), startTs: parseT(a.open), endTs: parseT(a.close) })));
2032
+ if (activeActs.length === 0) return null;
2033
+ const primary = pickEventPrimary(activeActs) || activeActs[0]; // 叙事活动/挑战活动优先于签到类
2034
+ const win = fmtWindow(primary.startTs, primary.endTs);
2035
+ return {
2036
+ banner: primary.name,
2037
+ bannerDatesRaw: win,
2038
+ bannerDates: win,
2039
+ eventHover: buildEventHover(activeActs)
2040
+ };
2041
+ }
2042
+
2043
+ // 终末地(FZ Wiki 经 host 代理,无 CORS):活动排期页
2044
+ async function fetchFzWikiEndfield(pageUrl, now = nowMs()) {
2045
+ const html = await proxyFetchText(pageUrl, "https://fz.wiki/");
2046
+ const d = parseFzWikiActivities(html, now);
2047
+ if (d) return d;
2048
+ throw new Error("fz-wiki-parse-empty");
2049
+ }
2050
+
2051
+ // 通用活动源解析(自定义条目/自定义活动来源地址用):抓取页面 → parseGenericEvents → {event, eventDates}
2052
+ async function tryParseGenericEvent(url, signal) {
2053
+ const html = await fetchHtmlText(url, signal);
2054
+ const cur = parseGenericEvents(html);
2055
+ if (cur && cur.banner) return {
2056
+ event: cur.banner,
2057
+ eventDates: cur.bannerDates || "",
2058
+ eventDatesRaw: cur.bannerDatesRaw || cur.bannerDates || ""
2059
+ };
2060
+ return null;
2061
+ }
2062
+
2063
+ // 原神(bwiki SMW 语义查询):活动一览页数据在 JS 动态加载(Dquery + SMW),
2064
+ // 改用 api.php?action=ask 直接查询「分类:活动」的开始/结束时间,选当期。
2065
+ // 属性:名称/开始时间/结束时间/所属版本;结束时间 9999/01/01 为永久活动占位(跳过)。
2066
+ // 查询 URL 由 fetchYsActivity 构造,浏览器直连(api.php 带 origin=* 有 CORS)。
2067
+ function parseSmwActivity(json) {
2068
+ const results = json?.query?.results || {};
2069
+ const now = nowMs();
2070
+ const covering = [];
2071
+ // SMW timestamp 是 UTC 秒,raw 形如 "1/2026/8/28/10/0/0/0"(服务器本地时间 +08)。
2072
+ // 用 raw 直接构造本地时间,避免 UTC 秒被本地时区再偏移。
2073
+ const parseRaw = (v) => {
2074
+ if (!v) return null;
2075
+ if (v.raw != null) {
2076
+ const p = String(v.raw).split("/");
2077
+ if (p.length >= 8) {
2078
+ const y = Number(p[1]), mo = Number(p[2]), d = Number(p[3]), h = Number(p[4]), mi = Number(p[5]);
2079
+ if (y && mo && d) return new Date(y, mo - 1, d, h || 0, mi || 0).getTime();
2080
+ }
2081
+ }
2082
+ // 回退:value 若是严格 ISO 本地时间则用之(避免把展示文本误判为时间)
2083
+ const iso = v.value != null ? String(v.value) : "";
2084
+ if (/^\d{4}-\d{1,2}-\d{1,2}\s+\d{1,2}:\d{2}/.test(iso)) {
2085
+ const t = new Date(iso.replace(" ", "T")).getTime();
2086
+ if (!Number.isNaN(t)) return t;
2087
+ }
2088
+ return null;
2089
+ };
2090
+ for (const [title, r] of Object.entries(results)) {
2091
+ const p = r.printouts || {};
2092
+ const nameArr = p["名称"] || [];
2093
+ const name = Array.isArray(nameArr) && nameArr[0] ? String(nameArr[0]) : title;
2094
+ const startTs = parseRaw(p["开始时间"]?.[0]);
2095
+ const endTs = parseRaw(p["结束时间"]?.[0]);
2096
+ if (startTs == null || endTs == null) continue;
2097
+ // 永久活动占位(9999 年)跳过
2098
+ if (endTs > 4102444800000) continue; // 2100-01-01
2099
+ if (startTs <= now && endTs >= now) {
2100
+ // 类型属性(如 剧情活动/常规活动/版本活动)→ 活动外显的类别优先级。
2101
+ // 注意 SMW 这里返回的是字符串数组(不是 {fulltext} 值对象),两种形态都兼容。
2102
+ const catArr = p["类型"] || [];
2103
+ const cat = catArr
2104
+ .map((x) => (typeof x === "string" ? x : String((x && (x.fulltext || x.value)) || "")))
2105
+ .filter(Boolean)
2106
+ .join("/");
2107
+ covering.push({ name, cat, startTs, endTs });
2108
+ }
2109
+ }
2110
+ if (covering.length === 0) return null;
2111
+ // 悬停按结束时间升序;外显按类别优先(剧情活动/挑战类优先于常规/网页类)
2112
+ const ordered = sortEventItems(covering);
2113
+ const best = pickEventPrimary(ordered) || ordered[0];
2114
+ const win = fmtWindow(best.startTs, best.endTs);
2115
+ return {
2116
+ banner: best.name,
2117
+ roles: "",
2118
+ bannerDates: win,
2119
+ bannerDatesRaw: win,
2120
+ eventHover: buildEventHover(ordered)
2121
+ };
2122
+ }
2123
+
2124
+ // 原神 SMW 活动查询(经 origin=* 直连)
2125
+ // 返回 EVENT_FETCHERS 契约格式 {event, eventDates, eventDatesRaw}(parseSmwActivity 产出卡池格式,这里转换)
2126
+ async function fetchYsActivity(signal) {
2127
+ const nowYear = new Date().getFullYear();
2128
+ 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";
2129
+ const apiUrl = "https://wiki.biligame.com/ys/api.php?action=ask&query=" + encodeURIComponent(q) + "&format=json&origin=*";
2130
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
2131
+ if (!res.ok) throw new Error("http-" + res.status);
2132
+ const json = await res.json();
2133
+ const d = parseSmwActivity(json);
2134
+ if (!d) return null;
2135
+ return {
2136
+ event: d.banner,
2137
+ eventDates: d.bannerDates || "",
2138
+ eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "",
2139
+ eventHover: d.eventHover || ""
2140
+ };
2141
+ }
2142
+
2143
+ // ---- 抓取器工厂 ----
2144
+ // mkMediaWiki:MediaWiki api.php JSON 源(追加 &origin=* 绕过 CORS)
2145
+ // mkRaw:直接抓取原始 HTML(非 MediaWiki 源,如 GachaTracker)
2146
+ // 经 host 同源代理的来源(蔚蓝系列 / 终末地 wiki.gg / 1999 等)不用工厂包装:
2147
+ // 其抓取器内部自行调用 proxyFetchText / proxyFetchJson(绕过 CORS 与 Referer 反爬)。
2148
+ // 抓取器签名:async (url, signal) → 数据对象 | null
2149
+ function mkMediaWiki(parse) {
2150
+ return async (url, signal) => {
2151
+ const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
2152
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
2153
+ if (!res.ok) throw new Error("http-" + res.status);
2154
+ const json = await res.json();
2155
+ const text = json?.parse?.text;
2156
+ if (typeof text !== "string") throw new Error("bad-json");
2157
+ return parse(text);
2158
+ };
2159
+ }
2160
+ function mkRaw(parse) {
2161
+ return async (url, signal) => {
2162
+ const apiUrl = url + (url.includes("?") ? "&" : "?") + "origin=*";
2163
+ const res = await transportFetchRaw(apiUrl, { signal, headers: { Accept: "application/json" } });
2164
+ if (!res.ok) throw new Error("http-" + res.status);
2165
+ return parse(await res.text());
2166
+ };
2167
+ }
2168
+ // bwiki 通用"选当期"包装(原神/星铁/绝区零/方舟)
2169
+ const pickCurrent = (parse) => (html) => selectCurrent(parse(html), nowMs());
2170
+ //#endregion
2171
+
2172
+ //#region proxy fetchers(经 host 代理)与统一来源注册表
2173
+ // 中文时间解析(繁体/简体共用):
2174
+ // "8月18日(二)維護後" / "9月1日(二)上午9點59分" / "晚間10點59分" / "08月20日 14:00"
2175
+ function parseZhTime(raw, nowYear) {
2176
+ const s = String(raw).trim();
2177
+ const md = s.match(/(\d{1,2})月(\d{1,2})日/);
2178
+ if (!md) return null;
2179
+ const mo = Number(md[1]), d = Number(md[2]);
2180
+ let h = 0, mi = 0;
2181
+ const tm = s.match(/(上午|下午|中午|凌晨|晚上|晚間)?\s*(\d{1,2})[點点](\d{1,2})?[分]?/);
2182
+ if (tm) {
2183
+ let hh = Number(tm[2]);
2184
+ const mm = tm[3] ? Number(tm[3]) : 0;
2185
+ const period = tm[1] || "";
2186
+ if ((period === "下午" || period === "晚上" || period === "晚間") && hh < 12) hh += 12;
2187
+ if (period === "中午" && hh < 12) hh += 12;
2188
+ if (period === "凌晨" && hh === 12) hh = 0;
2189
+ h = hh; mi = mm;
2190
+ }
2191
+ const ts = new Date(nowYear, mo - 1, d, h, mi).getTime();
2192
+ return { ts, text: `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}` };
2193
+ }
2194
+
2195
+ // 经 host 代理抓取文本 / JSON 两个函数的**实现**已移到外壳(92-dsh-env.js),
2196
+ // core 侧只在 15-env.js 保留同名薄封装(转调 coreEnv.transport)——这是 core 零宿主依赖的接缝之一。
2197
+
2198
+ // 解析繁体时间段:"8月18日(二)維護後 ~ 9月1日(二)上午9點59分"
2199
+ function parseBaZhRange(raw, nowYear) {
2200
+ const s = stripTags(raw);
2201
+ const parts = s.split(/[~~]/).map((x) => x.trim());
2202
+ if (parts.length < 2) return null;
2203
+ const a = parseZhTime(parts[0], nowYear);
2204
+ const b = parseZhTime(parts[1], nowYear);
2205
+ if (!a || !b) return null;
2206
+ return { startTs: a.ts, endTs: b.ts, startText: a.text, endText: b.text, raw: `${a.text} ~ ${b.text}` };
2207
+ }
2208
+
2209
+ // 解析更新日誌正文的日程表 → 行数组 {cat, name, range, dateRaw}
2210
+ function parseBaLogRows(content, nowYear) {
2211
+ const rows = [...content.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)].map((m) => m[1]);
2212
+ const items = [];
2213
+ for (const row of rows) {
2214
+ const cells = [...row.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => stripTags(m[1]));
2215
+ if (cells.length < 3) continue;
2216
+ const cat = cells[1] || "";
2217
+ const name = cells[2] || "";
2218
+ if (!cat || !name) continue;
2219
+ let range = parseBaZhRange(cells[0], nowYear);
2220
+ let relStart = false;
2221
+ let openEnded = false;
2222
+ if (!range && /維護(?:結束)?後|維護後/.test(cells[0])) {
2223
+ // 起点写"維護結束後"的日程行:先解析结束端,起点留待按本期维护结束时间补全(fetchBaGlobal)
2224
+ const parts = stripTags(cells[0]).split(/[~~]/).map((x) => x.trim());
2225
+ if (parts.length >= 2) {
2226
+ const b = parseZhTime(parts[parts.length - 1], nowYear);
2227
+ if (b) { range = { startTs: null, endTs: b.ts, startText: null, endText: b.text, raw: stripTags(cells[0]) }; relStart = true; }
2228
+ } else {
2229
+ // 只有「維護後」、官方未给结束端(如常駐化活動):保留该行,结束端留空、时间按原文展示,不做推算
2230
+ range = { startTs: null, endTs: null, startText: null, endText: null, raw: stripTags(cells[0]) };
2231
+ relStart = true;
2232
+ openEnded = true;
2233
+ }
2234
+ }
2235
+ items.push({ cat, name, range, dateRaw: cells[0], relStart, openEnded });
2236
+ }
2237
+ return items;
2238
+ }
2239
+
2240
+ // 蔚蓝档案·国际服:nexon 官方「更新日誌」board(3352) → 当期卡池 + 当期活动
2241
+ // 一次请求拿到更新日誌正文,从日程表同时提取 特選招募(卡池)与 活動劇情/總力戰(活动)
2242
+ async function fetchBaGlobal(logListUrl, signal, now = nowMs()) {
2243
+ const ref = "https://forum.nexon.com/bluearchiveTW/";
2244
+ const list = await proxyFetchJson(logListUrl, ref);
2245
+ const threads = Array.isArray(list?.threads) ? list.threads : [];
2246
+ // 每篇日志的维护日取标题日期("8/18(二) 更新日誌");日期跨年按当前年推断
2247
+ const y = new Date(now).getFullYear();
2248
+ const threadDate = (t) => {
2249
+ const m = String(t.title || "").match(/(\d{1,2})\/(\d{1,2})\(/);
2250
+ if (!m) return null;
2251
+ let d = new Date(y, Number(m[1]) - 1, Number(m[2]), 0, 0);
2252
+ if (d.getTime() > now + 45 * 864e5) d = new Date(y - 1, Number(m[1]) - 1, Number(m[2]), 0, 0);
2253
+ return d.getTime();
2254
+ };
2255
+ const dated = threads
2256
+ .map((t) => ({ t, ts: threadDate(t) }))
2257
+ .filter((x) => x.ts != null && /更新日誌/.test(String(x.t.title || "")))
2258
+ .sort((a, b) => b.ts - a.ts);
2259
+ // 窗口匹配:日志 i 覆盖 [其维护日, 下一篇(更早)日志维护日);取"最新且不晚于 now"的一篇
2260
+ const pick = dated.find((x) => x.ts <= now);
2261
+ if (!pick?.t?.threadId) return null;
2262
+ const detail = await proxyFetchJson(`https://forum.nexon.com/api/v1/thread/${pick.t.threadId}?alias=bluearchiveTW&countryCode=KR`, ref);
2263
+ const content = typeof detail?.content === "string" ? detail.content : "";
2264
+ if (!content) return null;
2265
+ const nowYear = new Date(now).getFullYear();
2266
+ const rows = parseBaLogRows(content, nowYear);
2267
+ // 维护结束时刻:从正文维护段"日期…上午10點~下午2點"取结束钟点,
2268
+ // 用于把日程表里起点为"維護結束後"的行补成显式起点
2269
+ const logTxt = String(content).replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ");
2270
+ const mEnd = logTxt.match(/日期\s*[::]\s*\d{1,2}月\d{1,2}日(?:[^。\n]{0,60}?)[~~-]\s*(上午|下午|中午|晚上)(\d{1,2})點(?:\s*(\d{1,2})分?)?/);
2271
+ let maintEndTs = null, maintEndText = "";
2272
+ const dayBase = new Date(pick.ts);
2273
+ if (mEnd) {
2274
+ let hh = Number(mEnd[2]);
2275
+ if ((mEnd[1] === "下午" || mEnd[1] === "晚上") && hh < 12) hh += 12;
2276
+ if (mEnd[1] === "中午" && hh < 12) hh += 12;
2277
+ const mi = mEnd[3] ? Number(mEnd[3]) : 0;
2278
+ maintEndTs = new Date(dayBase.getFullYear(), dayBase.getMonth(), dayBase.getDate(), hh, mi).getTime();
2279
+ maintEndText = `${String(dayBase.getMonth() + 1).padStart(2, "0")}-${String(dayBase.getDate()).padStart(2, "0")} ${String(hh).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
2280
+ }
2281
+ if (maintEndTs == null) { // 兜底:维护日 14:00
2282
+ maintEndTs = new Date(dayBase.getFullYear(), dayBase.getMonth(), dayBase.getDate(), 14, 0).getTime();
2283
+ maintEndText = `${String(dayBase.getMonth() + 1).padStart(2, "0")}-${String(dayBase.getDate()).padStart(2, "0")} 14:00`;
2284
+ }
2285
+ for (const r of rows) {
2286
+ if (r.relStart && r.range && r.range.startTs == null) {
2287
+ r.range.startTs = maintEndTs;
2288
+ r.range.startText = maintEndText;
2289
+ // 结束端缺失的行不改写 raw:保留日程原文,避免出现"09-08 14:00 ~"这种半个区间
2290
+ if (!r.openEnded) r.range.raw = `${maintEndText} ~ ${r.range.endText ?? ""}`.trim();
2291
+ }
2292
+ }
2293
+ // 当期卡池:特別特選招募 / 特選招募(时间覆盖 now)
2294
+ const bannerRow = rows.find((r) => /特選招募/.test(r.cat) && r.range && r.range.startTs <= now && r.range.endTs >= now);
2295
+ // 当期活动候选:活動劇情 > 迷你活動 > 總力戰/大決戰/制約解除決戰/綜合戰術考試;
2296
+ // 常駐化活動(名稱含「常駐」)优先级最低;只有起点、官方未给结束端的行同样纳入候选
2297
+ const activeRow = (r) => !!r.range && r.range.startTs != null && r.range.startTs <= now &&
2298
+ (r.openEnded || (r.range.endTs != null && r.range.endTs >= now));
2299
+ const evTier = (r) => {
2300
+ if (/活動劇情/.test(r.cat)) return 1;
2301
+ if (/迷你活動/.test(r.cat)) return 2;
2302
+ if (/總力戰|大決戰|制約解除決戰|綜合戰術考試/.test(r.cat)) return 3;
2303
+ return 9;
2304
+ };
2305
+ const evCandidates = rows.filter((r) => evTier(r) < 9 && activeRow(r));
2306
+ // ③ 统一排序:结束时间升序(常驻/未给结束端的行 endTs 为空,自然排最后);
2307
+ // 外显取排序第一条,悬停按同序逐行;长期/常驻玩法由 sortEventItems 一并剔除
2308
+ const evOrdered = sortEventItems(evCandidates.map((r) => ({
2309
+ name: r.name,
2310
+ cat: r.cat,
2311
+ startTs: r.openEnded ? null : r.range.startTs,
2312
+ endTs: r.openEnded ? null : r.range.endTs,
2313
+ raw: r.openEnded ? r.dateRaw : r.range.raw
2314
+ })));
2315
+ // 外显:类别优先(活動劇情/總力戰/大決戰 优先于 迷你活動/常駐类),同级内结束时间升序
2316
+ const eventRow = pickEventPrimary(evOrdered) || null;
2317
+ const evDates = eventRow
2318
+ ? (eventRow.startTs != null && eventRow.endTs != null ? fmtWindow(eventRow.startTs, eventRow.endTs) : (eventRow.raw || ""))
2319
+ : "";
2320
+ const eventHover = buildEventHover(evOrdered);
2321
+ const data = {
2322
+ banner: "",
2323
+ roles: "",
2324
+ bannerDates: "",
2325
+ event: "",
2326
+ eventDates: ""
2327
+ };
2328
+ if (bannerRow) {
2329
+ data.banner = bannerRow.cat; // 如「特別特選招募」「特選招募」
2330
+ data.roles = baRoleName(bannerRow.name); // 蔚蓝三服角色名:保留括号后缀 + 全角括号转半角
2331
+ data.bannerDates = bannerRow.range.raw;
2332
+ } else if (eventRow) {
2333
+ // 无卡池行时至少给出活动
2334
+ data.banner = eventRow.name;
2335
+ data.bannerDates = evDates;
2336
+ }
2337
+ // 卡池列悬停:日程表里同期所有招募行(特選招募/特別特選招募 等),每池"类别:成员"一行 + 时间
2338
+ const recRows = rows.filter((r) => /招募/.test(r.cat) && r.range && r.range.startTs != null && r.range.endTs != null && r.range.startTs <= now && r.range.endTs >= now);
2339
+ const poolHover = buildPoolHover(recRows.map((r) => ({
2340
+ name: r.cat,
2341
+ label: `${r.cat}\uFF1A${baRoleName(r.name)}`,
2342
+ startTs: r.range.startTs,
2343
+ endTs: r.range.endTs,
2344
+ raw: r.range.raw
2345
+ })));
2346
+ if (poolHover) data.bannerHover = poolHover;
2347
+ if (eventRow) {
2348
+ data.event = eventRow.name; // 活动名(去掉"活動劇情:"类类别前缀,精简展示)
2349
+ data.eventDates = evDates;
2350
+ if (eventRow.openEnded) data.eventDatesRaw = eventRow.dateRaw;
2351
+ }
2352
+ if (eventHover) data.eventHover = eventHover;
2353
+ if (!data.banner || !data.bannerDates) return null;
2354
+ return data;
2355
+ }
2356
+
2357
+ // 终末地(wiki.gg 经 host 代理):抓取 Headhunting/Banners HTML → parseEndfieldCurrent
2358
+ // wiki.gg 校验 Referer(非 wiki.gg 域名 403),经代理后 Referer=目标 origin 满足要求
2359
+ async function fetchEndfieldWikiGg(proxyUrl) {
2360
+ const html = await proxyFetchText(proxyUrl, "https://endfield.wiki.gg/");
2361
+ return parseEndfieldCurrent(html);
2362
+ }
2363
+
2364
+ // ---- 蔚蓝档案·日服 官方公告(api-web.bluearchive.jp)----
2365
+ // 「ピックアップ募集紹介」条目结构(同一公告内多池、逐池给出):
2366
+ // ▼ピックアップ対象
2367
+ // ピックアップ名: 「そして夏は爆破で終わる!」
2368
+ // ピックアップ生徒:★3「カスミ(水着)」
2369
+ // ▼実施期間 2026年9月9日(水) メンテナンス後 ~ 2026年9月23日(水・祝) 10:59
2370
+ // 时间按 JST(+09:00) 解析(展示时转本地);起点写「メンテナンス後」时取同期维护公告
2371
+ // 「▼実施時間 … ~ … 17:00前後」的结束时刻。活动取同期「イベント」条目的開催期間。
2372
+ function parseBaJpNews(json, now = nowMs()) {
2373
+ const rows = Array.isArray(json?.data?.rows) ? json.data.rows : [];
2374
+ const clean = (s) => String(s || "")
2375
+ .replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
2376
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
2377
+ .replace(/\s+/g, " ").trim();
2378
+ // JST 时间戳(JST = UTC+9)
2379
+ const jstTs = (y, mo, d, h, mi) => Date.UTC(y, mo - 1, d, h - 9, mi);
2380
+ const jpDate = (s) => {
2381
+ const m = String(s).match(/(\d{4})年(\d{1,2})月(\d{1,2})日[^0-9]{0,8}(\d{1,2}):(\d{2})/);
2382
+ return m ? jstTs(+m[1], +m[2], +m[3], +m[4], +m[5]) : null;
2383
+ };
2384
+ const afterMaint = (s) => /メンテナンス後/.test(String(s));
2385
+ // 维护结束时刻("メンテナンス後"的起点):最新一篇维护公告的実施時間 结束端
2386
+ let maintEnd = null;
2387
+ for (const it of rows) {
2388
+ const m = clean(it.content).match(/実施時間\s*([^~~]{4,48})[~~]\s*([^前]{4,48})/);
2389
+ if (!m) continue;
2390
+ const e = jpDate(m[2]);
2391
+ if (e != null) { maintEnd = e; break; }
2392
+ }
2393
+ // 当期卡池:最新一篇含「ピックアップ名/ピックアップ生徒」的募集公告
2394
+ let pools = null, win = null;
2395
+ for (const it of rows) {
2396
+ const text = clean(it.content);
2397
+ if (!/ピックアップ募集/.test(text) || !/ピックアップ名/.test(text)) continue;
2398
+ const found = [...text.matchAll(/ピックアップ名:\s*「([^」]+)」\s*ピックアップ生徒:\s*(?:★\d)?「([^」]+)」/g)]
2399
+ .map((m) => ({ name: m[1], student: m[2] }));
2400
+ if (found.length === 0) continue;
2401
+ const windows = [...text.matchAll(/実施期間\s*([^~~]{4,64})[~~]\s*([^▼]{4,48})/g)].map((m) => {
2402
+ const a = m[1].trim(), b = m[2].trim();
2403
+ const startTs = afterMaint(a) ? maintEnd : jpDate(a);
2404
+ const endTs = jpDate(b);
2405
+ return { startTs: startTs ?? null, endTs: endTs ?? null, raw: `${afterMaint(a) ? "メンテナンス後" : a} ~ ${b}` };
2406
+ });
2407
+ const fallback = windows[0] || { startTs: null, endTs: null, raw: "" };
2408
+ pools = found.map((f, i) => Object.assign({ name: f.name, student: f.student }, windows[i] || fallback));
2409
+ win = fallback;
2410
+ break;
2411
+ }
2412
+ if (!pools) return null;
2413
+ // 只保留覆盖当前时刻的池(起点缺省时按"结束在未来"宽松判定)
2414
+ const cur = pools.filter((p) => p.endTs != null && p.endTs >= now && (p.startTs == null || p.startTs <= now));
2415
+ if (cur.length === 0) return null;
2416
+ const dates = win && win.startTs != null && win.endTs != null ? fmtWindow(win.startTs, win.endTs) : (win?.raw || "");
2417
+ const data = {
2418
+ banner: cur[0].name,
2419
+ roles: [...new Set(cur.map((p) => p.student))].join("、"),
2420
+ bannerDates: dates,
2421
+ bannerDatesRaw: win?.raw || dates,
2422
+ startTs: cur[0].startTs,
2423
+ endTs: cur[0].endTs
2424
+ };
2425
+ // 卡池列悬停:每池「池名:生徒」一行 + 时间(同窗口合并,结束时间升序)
2426
+ const hover = buildPoolHover(cur.map((p) => ({
2427
+ name: p.name,
2428
+ label: `${p.name}\uFF1A${p.student}`,
2429
+ startTs: p.startTs,
2430
+ endTs: p.endTs,
2431
+ raw: p.raw
2432
+ })));
2433
+ if (hover) data.bannerHover = hover;
2434
+ // 活动:同期「イベント」条目的開催期間
2435
+ for (const it of rows) {
2436
+ const sum = clean(it.summary);
2437
+ const text = clean(it.content);
2438
+ const m = (sum || text).match(/【(復刻)?イベント】「([^」]+)」/);
2439
+ if (!m) continue;
2440
+ const pr = text.match(/開催期間\s*([^~~]{4,64})[~~]\s*([^▼]{4,48})/);
2441
+ if (!pr) continue;
2442
+ const a = pr[1].trim(), b = pr[2].trim();
2443
+ const startTs = afterMaint(a) ? maintEnd : jpDate(a);
2444
+ const endTs = jpDate(b);
2445
+ if (startTs == null || endTs == null || startTs > now || endTs < now) continue;
2446
+ data.event = m[1] ? `${m[2]}(復刻)` : m[2];
2447
+ data.eventDates = fmtWindow(startTs, endTs);
2448
+ data.eventDatesRaw = `${afterMaint(a) ? "メンテナンス後" : a} ~ ${b}`;
2449
+ break;
2450
+ }
2451
+ return data;
2452
+ }
2453
+
2454
+ // 日服卡池默认抓取器:官网新闻接口优先;失败或无当期募集 → 回退 GameKee 当期卡池(仅池名+档期)。
2455
+ // 只返回卡池字段:活动字段由活动源单独负责(卡池/活动解耦,避免活动源失败时静默混入官方活动)
2456
+ async function fetchBaJpGacha(url, signal, now = nowMs()) {
2457
+ try {
2458
+ const json = await proxyFetchJson(url, "https://bluearchive.jp/");
2459
+ const d = parseBaJpNews(json, now);
2460
+ if (d) {
2461
+ delete d.event;
2462
+ delete d.eventDates;
2463
+ delete d.eventDatesRaw;
2464
+ return d;
2465
+ }
2466
+ } catch { /* 官方失败 → GameKee 兜底 */ }
2467
+ return fetchGameKeeBa("jp");
2468
+ }
2469
+
2470
+ // 日服活动备选抓取器:同一个官方接口的「イベント」条目(只取 event 字段)
2471
+ async function fetchBaJpOfficialEvent(url, signal, now = nowMs()) {
2472
+ const json = await proxyFetchJson(url, "https://bluearchive.jp/");
2473
+ const d = parseBaJpNews(json, now);
2474
+ if (!d || !d.event) return null;
2475
+ return { event: d.event, eventDates: d.eventDates || "", eventDatesRaw: d.eventDatesRaw || d.eventDates || "" };
2476
+ }
2477
+
2478
+ // ---- GameKee(蔚蓝档案)----
2479
+ // 解析标题里的排期:【8/18~9/01】 / 【8月26日 ~ 9月9日】 → {startText, endText, startTs, endTs}
2480
+ function parseGkRange(title, nowYear) {
2481
+ const m = String(title).match(/【([^】]+)】/);
2482
+ if (!m) return null;
2483
+ const inner = m[1].replace(/\s+/g, "");
2484
+ const parts = inner.split(/[~~\-—]/);
2485
+ if (parts.length < 2) return null;
2486
+ const parseGkTime = (p) => {
2487
+ // 8/18 或 8月18日(日服可能带 日)
2488
+ const md = p.match(/(\d{1,2})[\/月](\d{1,2})日?/);
2489
+ if (!md) return null;
2490
+ const mo = Number(md[1]), d = Number(md[2]);
2491
+ const ts = new Date(nowYear, mo - 1, d, 0, 0).getTime();
2492
+ return { ts, text: `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} 00:00` };
2493
+ };
2494
+ const a = parseGkTime(parts[0]);
2495
+ const b = parseGkTime(parts[1]);
2496
+ if (!a || !b) return null;
2497
+ return { startTs: a.ts, endTs: b.ts, startText: a.text, endText: b.text, raw: `${a.text} ~ ${b.text}` };
2498
+ }
2499
+
2500
+ // GameKee:按服务器拉当期卡池+活动(标题含排期)
2501
+ // serverKey: "jp"|"global";返回 {banner, roles, bannerDates, event, eventDates}
2502
+ async function fetchGameKeeBa(serverKey) {
2503
+ const ref = "https://www.gamekee.com/ba/huodong/15";
2504
+ const headers = { "game-alias": "ba" };
2505
+ // 1. 目录树 → 找"当期活动 | 当期卡池"子条目
2506
+ const tree = await proxyFetchJson("https://www.gamekee.com/v1/wiki/entry?id=15", ref, headers);
2507
+ const list = tree?.data?.entry_list || [];
2508
+ let currentCat = null;
2509
+ const find = (nodes) => {
2510
+ for (const n of nodes || []) {
2511
+ // 名称可能含隐藏字符,用 includes 匹配
2512
+ if (String(n.name || "").includes("\u5F53\u671F\u6D3B\u52A8") && String(n.name || "").includes("\u5361\u6C60")) { currentCat = n; return; }
2513
+ if (n.child) find(n.child);
2514
+ }
2515
+ };
2516
+ find(list);
2517
+ if (!currentCat?.child) return null;
2518
+ const child = currentCat.child;
2519
+ // 找该服条目:日服(jp) 活动/卡池;国际服(global) 活动/卡池
2520
+ const isJp = serverKey === "jp";
2521
+ const kw = isJp ? "日服" : "国际服";
2522
+ const eventEntry = child.find((c) => c.name.includes(kw + "活动"));
2523
+ const bannerEntry = child.find((c) => c.name.includes(kw + "当期卡池"));
2524
+ // 2. 拉标题(含排期)
2525
+ const getTitle = async (entry) => {
2526
+ if (!entry?.content_id) return null;
2527
+ const d = await proxyFetchJson(`https://www.gamekee.com/v1/content/detail/${entry.content_id}`, ref, headers);
2528
+ return typeof d?.data?.title === "string" ? d.data.title : "";
2529
+ };
2530
+ const bannerTitle = bannerEntry ? await getTitle(bannerEntry) : "";
2531
+ const eventTitle = eventEntry ? await getTitle(eventEntry) : "";
2532
+ const nowYear = new Date().getFullYear();
2533
+ const data = { banner: "", roles: "", bannerDates: "", event: "", eventDates: "" };
2534
+ if (bannerTitle) {
2535
+ const rng = parseGkRange(bannerTitle, nowYear);
2536
+ if (rng) {
2537
+ // 卡池名:只去「(蔚蓝档案)(日服/国际服)当期卡池(评测):」这类表头与末尾日期括号,
2538
+ // 池名里的标点(! ~ ! 等)一律保留
2539
+ data.banner = String(bannerTitle)
2540
+ .replace(/^(?:蔚蓝档案)?\s*(?:日服|国际服|国服)?\s*(?:当期)?\s*(?:卡池|招募|评测)[^::]*[::]\s*/, "")
2541
+ .replace(/【[^】]*\d[^】]*】/g, "")
2542
+ .trim();
2543
+ data.bannerDates = rng.raw;
2544
+ }
2545
+ }
2546
+ if (eventTitle) {
2547
+ const rng = parseGkRange(eventTitle, nowYear);
2548
+ if (rng) {
2549
+ // 活动名:只删可识别的游戏/服别前缀与"活动攻略整理"类后缀 + 末尾日期括号;
2550
+ // 名字里的标点(! ~ ! ~ 等)与「复刻」都保留
2551
+ // 如 "蔚蓝档案国际服 比赛开始~目标!满贯全垒打!~ 复刻活动攻略整理【09/01~09/15】"
2552
+ // → "比赛开始~目标!满贯全垒打!~ 复刻"
2553
+ const cleaned = String(eventTitle)
2554
+ .replace(/【[^】]*\d[^】]*】/g, "")
2555
+ .replace(/^蔚蓝档案\s*/, "")
2556
+ .replace(/^(?:日服|国际服|国服)?\s*(?:当期)?\s*(?:活动|卡池)(?:攻略|评测)?[^::]*[::]\s*/, "")
2557
+ .replace(/^(?:日服|国际服|国服)\s*(?:当期)?\s*(?:活动|卡池)\s*/, "")
2558
+ .replace(/^(?:日服|国际服|国服)\s*[::]?\s*/, "")
2559
+ .replace(/活动一图攻略整理|活动攻略整理|攻略整理$/, "")
2560
+ .trim();
2561
+ data.event = cleaned || String(eventTitle).replace(/【[^】]*\d[^】]*】/g, "").trim();
2562
+ data.eventDates = rng.raw;
2563
+ }
2564
+ }
2565
+ if (!data.banner || !data.bannerDates) return null;
2566
+ return data;
2567
+ }
2568
+
2569
+ // 蔚蓝国服(官网 bluearchive-cn.com):news/list → 最新维护更新说明 → 当期卡池/活动名 + 维护起止
2570
+ // 时间:维护日 14:00 ~ 下次维护前(约 +14 天,取维护日开始、预加载下一期预告前)
2571
+ async function fetchBaCn(listUrl, now = nowMs()) {
2572
+ const H = { "game-alias": "ba" };
2573
+ const ref = "https://bluearchive-cn.com/";
2574
+ const nowYear = new Date(now).getFullYear();
2575
+ const list = await proxyFetchJson(listUrl, ref, H);
2576
+ const rows = list?.data?.rows || [];
2577
+ const clean = (s) => String(s || "")
2578
+ .replace(/<br\s*\/?>/gi, "\n")
2579
+ .replace(/<[^>]+>/g, "")
2580
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
2581
+ .replace(/[ \t]+/g, " ").replace(/\n\s*\n+/g, "\n").trim();
2582
+ // 窗口匹配:对最新的若干篇"维护更新说明",按其正文首个维护时间(起)+14 天(卡池窗口)判定是否覆盖 now;
2583
+ // 列表 content 被截断时按 id 取详情补齐再判定;选覆盖 now 的那一篇(不再无条件取最新)
2584
+ const mains = rows.filter((n) => /维护更新说明/.test(n.title || ""));
2585
+ let maintTitle = null;
2586
+ for (const it of mains.slice(0, 8)) {
2587
+ let txt = clean(it.content || "");
2588
+ let first = txt.match(/(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
2589
+ if (!first && it.id) {
2590
+ try {
2591
+ const det = await proxyFetchJson(`https://bluearchive-cn.com/api/news/detail?id=${it.id}`, ref, H);
2592
+ txt = clean(det?.data?.news?.content || "");
2593
+ first = txt.match(/(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
2594
+ } catch { /* 跳过该候选 */ }
2595
+ }
2596
+ if (!first) continue;
2597
+ const mo = Number(first[1]), d = Number(first[2]);
2598
+ const startTs = new Date(nowYear, mo - 1, d, Number(first[3]), Number(first[4])).getTime();
2599
+ const endTs = new Date(nowYear, mo - 1, d + 14, 13, 59).getTime();
2600
+ if (startTs <= now && now <= endTs) { maintTitle = it; break; }
2601
+ }
2602
+ if (!maintTitle?.id) return null;
2603
+ const detail = await proxyFetchJson(`https://bluearchive-cn.com/api/news/detail?id=${maintTitle.id}`, ref, H);
2604
+ const content = detail?.data?.news?.content || "";
2605
+ if (!content) return null;
2606
+ // HTML → 文本
2607
+ const text = String(content)
2608
+ .replace(/<br\s*\/?>/gi, "\n")
2609
+ .replace(/<[^>]+>/g, "")
2610
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
2611
+ .replace(/&ldquo;/g, "「").replace(/&rdquo;/g, "」")
2612
+ .replace(/&hellip;/g, "…").replace(/&times;/g, "×").replace(/&bull;/g, "·")
2613
+ .replace(/\n\s*\n+/g, "\n").trim();
2614
+ // 当期卡池名(第一个"更新限时招募【X】"或复刻)
2615
+ const bannerM = text.match(/更新限时招募【([^】]+)】/);
2616
+ const bannerR = text.match(/更新限时复刻招募【([^】]+)】/);
2617
+ // 当期活动名(第一个"更新限时活动【X】")
2618
+ const eventM = text.match(/更新限时活动【([^】]+)】/);
2619
+ // 维护开始时间 "08月20日 14:00"
2620
+ const maintM = text.match(/(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
2621
+ const fmt = (mo, d, h, mi) => `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
2622
+ let bannerDates = "", eventDates = "";
2623
+ if (maintM) {
2624
+ const mo = Number(maintM[1]), d = Number(maintM[2]);
2625
+ const start = fmt(mo, d, Number(maintM[3]), Number(maintM[4]));
2626
+ // 卡池结束:约 14 天后(下一期维护)
2627
+ const end = new Date(nowYear, mo - 1, d + 14, 13, 59);
2628
+ bannerDates = `${start} ~ ${fmt(end.getMonth() + 1, end.getDate(), end.getHours(), end.getMinutes())}`;
2629
+ // 活动结束:约 28 天后(蓝档国服活动通常比卡池多一期,如和乐庆典到 09-17 13:59)
2630
+ const eEnd = new Date(nowYear, mo - 1, d + 28, 13, 59);
2631
+ eventDates = `${start} ~ ${fmt(eEnd.getMonth() + 1, eEnd.getDate(), eEnd.getHours(), eEnd.getMinutes())}`;
2632
+ }
2633
+ // 公告里同期全部招募池(更新限时招募 / 更新限时限定复刻招募 / 更新限时复刻招募):
2634
+ // 池名 + 该行成员名,如
2635
+ // 2、更新限时招募【夏日思绪长…】,…全新3★成员「桔梗(泳装)」、2★成员「莲华(泳装)」登场
2636
+ const recPools = [];
2637
+ for (const m of text.matchAll(/更新限时(?:限定复刻|复刻|)招募【([^】]+)】([^\n]*)/g)) {
2638
+ const members = [...new Set([...m[2].matchAll(/(?:\d★)?(?:限定)?成员\s*[「“"]([^」”"]+)[」”"]/g)].map((x) => baRoleName(x[1].trim())).filter(Boolean))];
2639
+ recPools.push({ name: m[1], members });
2640
+ }
2641
+ // 外显:合并同期各池成员(与其它游戏"同窗口角色合并"一致;此前只取第一个池的成员,导致外显不全)
2642
+ const roleNames = [...new Set(recPools.flatMap((p) => p.members))];
2643
+ const data = {
2644
+ banner: bannerM ? bannerM[1] : (bannerR ? `复刻·${bannerR[1]}` : ""),
2645
+ roles: roleNames.join("、"),
2646
+ bannerDates,
2647
+ event: eventM ? eventM[1] : "",
2648
+ eventDates
2649
+ };
2650
+ // 卡池列悬停:每池"池名:成员"一行;公告只给整期维护窗口 → 各池窗口相同,时间按"相同窗口合并"只在末尾写一遍
2651
+ if (maintM) {
2652
+ const mo = Number(maintM[1]), d = Number(maintM[2]);
2653
+ const sTs = new Date(nowYear, mo - 1, d, Number(maintM[3]), Number(maintM[4])).getTime();
2654
+ const eTs = new Date(nowYear, mo - 1, d + 14, 13, 59).getTime();
2655
+ const hover = buildPoolHover(recPools.map((p) => ({
2656
+ name: p.name,
2657
+ label: `${p.name}${p.members.length ? `\uFF1A${p.members.join("、")}` : ""}`,
2658
+ startTs: sTs,
2659
+ endTs: eTs,
2660
+ raw: bannerDates
2661
+ })));
2662
+ if (hover) data.bannerHover = hover;
2663
+ }
2664
+ if (!data.banner) return null;
2665
+ return data;
2666
+ }
2667
+
2668
+ // 重返未来:1999 征集名增强源(小米游戏中心官方资讯流,SSR 页免登录可抓)
2669
+ // 官网资讯接口不发布征集名;小米游戏中心游戏页(game.xiaomi.com/game/62346241)的 SSR 数据
2670
+ // 含官方账号「神秘学研究员」最新 3 条资讯全文,其中「活动征集」公告带真实征集名与征集时间
2671
+ // (如【烈火悬流无尽】8/13 10:00-9/3 4:59、【湖的馈赠】自选六星 8/28 5:00-9/14 4:59)。
2672
+ // 主池优先:只认"征集时间覆盖当前时刻"且带定向 UP 的征集公告,且 UP 角色属于官网「新增角色」
2673
+ // (官方 SixStar 列表,如 赫多涅/纳西索斯)——自选六星池(无定向 UP)不作为主池;
2674
+ // 无主池匹配返回 null(由官网解析兜底显示角色名)。多条主池匹配取最新发布者。
2675
+ async function fetchXiaomiR99Gacha(gamePageUrl, officialSixStars) {
2676
+ const html = await proxyFetchText(gamePageUrl, "https://game.xiaomi.com/");
2677
+ const start = html.indexOf('"official":{"viewpoints":{"infos":[');
2678
+ if (start < 0) return null;
2679
+ const raw = html.slice(start);
2680
+ const marks = [...raw.matchAll(/\{"viewpointId":"/g)].map((x) => x.index);
2681
+ if (marks.length === 0) return null;
2682
+ const now = nowMs();
2683
+ const nowYear = new Date().getFullYear();
2684
+ const fmt = (mo, d, h, mi) => `${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}`;
2685
+ let best = null;
2686
+ for (let i = 0; i < marks.length; i++) {
2687
+ const seg = raw.slice(marks[i], i + 1 < marks.length ? marks[i + 1] : raw.length);
2688
+ const title = (seg.match(/"title":"([^"]*)"/) || [])[1] || "";
2689
+ const texts = [...seg.matchAll(/"contentType":1,"positionIndex":\d+,"content":"([\s\S]*?)"/g)].map((x) => x[1]);
2690
+ const content = texts.join(" ").replace(/\\u003c/g, "<").replace(/\\u003e/g, ">").replace(/\\u002F/g, "/").replace(/\\n/g, " ");
2691
+ if (!/征集/.test(title + content)) continue;
2692
+ const nameM = title.match(/【([^】]+)】活动征集/) || content.match(/【([^】]+)】活动征集/);
2693
+ if (!nameM) continue;
2694
+ // 征集时间:"8/28 5:00-9/14 4:59" / "8/13 10:00 - 9/3 4:59"
2695
+ const timeM = content.match(/征集(?:开放)?时间[◀◀::\s]*(\d{1,2})\/(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?\s*[-—~]\s*(\d{1,2})\/(\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?/);
2696
+ if (!timeM) continue;
2697
+ const sm = Number(timeM[1]), sd = Number(timeM[2]), sh = timeM[3] ? Number(timeM[3]) : 0, smi = timeM[4] ? Number(timeM[4]) : 0;
2698
+ const em = Number(timeM[5]), ed = Number(timeM[6]), eh = timeM[7] ? Number(timeM[7]) : 0, emi = timeM[8] ? Number(timeM[8]) : 0;
2699
+ const startTs = new Date(nowYear, sm - 1, sd, sh, smi).getTime();
2700
+ // 跨年(如 12/28-1/5)结束补下一年
2701
+ const endTs = new Date(em < sm ? nowYear + 1 : nowYear, em - 1, ed, eh, emi).getTime();
2702
+ if (startTs > now || endTs < now) continue; // 只取覆盖当期的征集
2703
+ // 主池判定:带定向 UP(【X】受邀概率UP)且 UP 角色属于官网新增角色
2704
+ const upM = title.match(/【([^】]+)】受邀概率UP/) || content.match(/【([^】]+)】受邀概率UP/);
2705
+ const upName = upM ? cleanRoles(upM[1]).replace(/[()()].*$/, "") : "";
2706
+ const isMain = upName !== "" && Array.isArray(officialSixStars) && officialSixStars.includes(upName);
2707
+ if (!isMain) continue; // 自选六星池等非主池不作为当期卡池
2708
+ const cand = {
2709
+ banner: nameM[1],
2710
+ bannerDates: `${fmt(sm, sd, sh, smi)} ~ ${fmt(em, ed, eh, emi)}`,
2711
+ roles: upName,
2712
+ order: i
2713
+ };
2714
+ // 多条主池覆盖当期时取最新发布(feed 靠前者更新)
2715
+ if (!best || cand.order < best.order) best = cand;
2716
+ }
2717
+ return best ? { banner: best.banner, bannerDates: best.bannerDates, roles: best.roles } : null;
2718
+ }
2719
+
2720
+ // 重返未来:1999(官网 re.bluepoch.com 新闻 API,POST 经 host 代理)
2721
+ // 列表接口(informationType=2 资讯)按上线时间倒序返回含全文的公告,
2722
+ // 取最新一期「版本更新维护公告」:当期卡池(首位6星角色名,官网无征集名)/ 当期活动 / 维护起止 + 下一期维护日
2723
+ async function fetchR99(listUrl, signal, now = nowMs()) {
2724
+ const ref = "https://re.bluepoch.com/";
2725
+ const list = await proxyFetchJson(listUrl, ref, {}, { current: 1, pageSize: 30, informationType: 2 });
2726
+ const items = list?.data?.pageData || [];
2727
+ const nowYear = new Date(now).getFullYear();
2728
+ const cleanText = (raw) => String(raw)
2729
+ .replace(/<br\s*\/?>/gi, "\n")
2730
+ .replace(/<[^>]+>/g, "")
2731
+ .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&")
2732
+ .replace(/&ldquo;/g, "「").replace(/&rdquo;/g, "」")
2733
+ .replace(/&hellip;/g, "…").replace(/&times;/g, "×").replace(/&bull;/g, "·")
2734
+ .replace(/\n\s*\n+/g, "\n").trim();
2735
+ // 单条版本公告的"版本窗口"(仅用于判断哪一期覆盖当前,不再用于展示):
2736
+ // 起点 = 本篇维护结束时刻;结束端 = **比本篇更新的一期维护公告的维护开始时间**;
2737
+ // 若还没有更新的一期(下一版本公告未发布)→ 按"维护起 + 42 天"兜底(1999 版本实测 21~42 天)。
2738
+ // 注意:不再使用公告里「可在X月X日上午5点之后」——实测那是**心相观测商店兑换**说明,
2739
+ // 曾据此把版本判成提前结束,导致版本中后期整格空白。
2740
+ const maintWindow = (text, olderThanIdx) => {
2741
+ const maintM = text.match(/【维护时间】\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})\s*-\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})/);
2742
+ if (!maintM) return null;
2743
+ const mk = (y, mo, d, h, mi) => new Date(y, mo - 1, d, h, mi).getTime();
2744
+ const sm = Number(maintM[1]), mm = Number(maintM[2]), md = Number(maintM[3]);
2745
+ const startTs = mk(Number(maintM[6]), Number(maintM[7]), Number(maintM[8]), Number(maintM[9]), Number(maintM[10]));
2746
+ let endTs = null;
2747
+ // 列表按发布时间倒序:下标更小的一期更新 → 取其维护开始时间作为本篇结束端
2748
+ for (let i = olderThanIdx - 1; i >= 0; i--) {
2749
+ const t2 = cleanText(versions[i].content);
2750
+ const m2 = t2.match(/【维护时间】\s*(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})/);
2751
+ if (!m2) continue;
2752
+ const cand = mk(Number(m2[1]), Number(m2[2]), Number(m2[3]), Number(m2[4]), Number(m2[5]));
2753
+ if (cand > startTs) { endTs = cand; break; }
2754
+ }
2755
+ if (endTs == null) endTs = new Date(sm, mm - 1, md + 42, 4, 59).getTime();
2756
+ return { maintM, startTs, endTs };
2757
+ };
2758
+ // 窗口匹配:选"版本更新维护公告"中版本窗口覆盖 now 的那一期(不再无条件取最新)
2759
+ const versions = items.filter((n) => /版本更新维护公告/.test(n.title || "") && n.content);
2760
+ let maint = null, win = null, text = "";
2761
+ for (let i = 0; i < versions.length; i++) {
2762
+ const n = versions[i];
2763
+ const t = cleanText(n.content);
2764
+ const w = maintWindow(t, i);
2765
+ if (w && w.startTs <= now && w.endTs >= now) { maint = n; win = w; text = t; break; }
2766
+ }
2767
+ if (!maint) {
2768
+ // 官方无当期覆盖 → 小米资讯流当期主池兜底(UP 名单取最新版本公告)
2769
+ let sixStars = [];
2770
+ if (versions[0]) {
2771
+ sixStars = [...cleanText(versions[0].content).matchAll(/6星角色「([^」]+)」/g)]
2772
+ .map((m) => cleanRoles(m[1]).replace(/[()()].*$/, ""))
2773
+ .filter(Boolean);
2774
+ }
2775
+ try {
2776
+ const xiaomi = await fetchXiaomiR99Gacha("https://game.xiaomi.com/game/62346241", sixStars);
2777
+ if (xiaomi && xiaomi.banner && xiaomi.bannerDates) {
2778
+ return {
2779
+ banner: xiaomi.banner,
2780
+ roles: xiaomi.roles || "",
2781
+ bannerDates: xiaomi.bannerDates,
2782
+ bannerDatesRaw: xiaomi.bannerDates,
2783
+ event: ""
2784
+ };
2785
+ }
2786
+ } catch { /* 兜底失败 → 返回空,由上层"失败沿用上次成功数据"保留展示原数据 */ }
2787
+ return null;
2788
+ }
2789
+ // —— 官方当期公告解析(text 已清洗)——
2790
+ // 当期新增角色(当期多池):只取「版本全新内容一览 → 新增角色」段内的 6 星角色,
2791
+ // 避免把复刻/心相/其它段的角色也算进来;多池外显合并角色(如 赫多涅、纳西索斯)
2792
+ const newSeg = text.match(/新增角色([\s\S]{0,220}?)(?=\d+\s*[.、]\s*新增|【|$)/);
2793
+ const newSix = (newSeg ? [...newSeg[1].matchAll(/6星角色「([^」]+)」/g)] : [])
2794
+ .map((m) => cleanRoles(m[1]).replace(/[()()].*$/, ""))
2795
+ .filter(Boolean);
2796
+ const sixStars = newSix.length > 0
2797
+ ? newSix
2798
+ : [...text.matchAll(/6星角色「([^」]+)」/g)].map((m) => cleanRoles(m[1]).replace(/[()()].*$/, "")).filter(Boolean);
2799
+ const bannerName = sixStars[0] || "";
2800
+ // 卡池时间:官网不发布逐池征集时间,靠"下一期维护/版本周期"推算并不可靠 →
2801
+ // 外显暂时固定为简短文案(等有可靠时间源再显示真实起止)
2802
+ const bannerDates = "暂无时间信息";
2803
+ // 当期活动:新增活动列表第 2 项(首位6星角色剧情活动,"「赫多涅·凡人或英雄」")
2804
+ const actM = text.match(/活动正篇[,,]\s*「([^」]+)」/);
2805
+ const eventName = actM ? String(actM[1]).replace(/^[^·]+·/, "") : "";
2806
+ // 活动时间与卡池同一规则:官网不发布可靠起止,外显固定为同一文案
2807
+ const eventDates = bannerDates;
2808
+ const data = {
2809
+ banner: bannerName,
2810
+ roles: sixStars.join("、"),
2811
+ bannerDates,
2812
+ event: eventName,
2813
+ eventDates
2814
+ };
2815
+ if (!data.banner || !data.bannerDates) return null;
2816
+ // 征集名增强:小米官方资讯流有"主池"征集公告(UP 属于官网新增角色)时,
2817
+ // 用真实征集名/征集时间/UP 角色覆盖卡池字段;无主池匹配则保持官网"角色名"显示
2818
+ try {
2819
+ const xiaomi = await fetchXiaomiR99Gacha("https://game.xiaomi.com/game/62346241", sixStars);
2820
+ if (xiaomi && xiaomi.banner && xiaomi.bannerDates) {
2821
+ data.banner = xiaomi.banner;
2822
+ data.bannerDates = xiaomi.bannerDates;
2823
+ data.roles = xiaomi.roles || "";
2824
+ }
2825
+ } catch { /* 增强源失败不影响官网解析结果 */ }
2826
+ return data;
2827
+ }
2828
+
2829
+ // ---- 统一来源注册表 ----
2830
+ // 卡池来源与活动来源完全独立,契约如下:
2831
+ // - GACHA_FETCHERS[id]:卡池字段抓取器(async (url, signal) → 数据对象 | null),
2832
+ // 数据对象形如 {banner, roles?, bannerDates, event?, eventDates?};
2833
+ // 当活动源与卡池源同 URL 时,event/eventDates 由卡池载荷复用(避免重复请求)。
2834
+ // - EVENT_FETCHERS[id]:活动源注册表({ 默认抓取器, 备选抓取器... }),
2835
+ // 抓取器同 GACHA_FETCHERS 契约;fetchEntry 只取其中的 event/eventDates 字段。
2836
+ // 活动源与卡池源不同 URL 时独立抓取(来源选择互不影响)。
2837
+ // - altSources / eventAltSources:备选源列表,字段为 { label, fetcher, url? , id? }
2838
+ // (url = 该来源的地址;id = 抓取器自带地址的内部来源标识)。
2839
+ // 设置页选中后按 url ?? id 路由到对应抓取器;是否走 host 代理由抓取器自己决定。见 normalizeSourceId。
2840
+ const GACHA_FETCHERS = {
2841
+ genshin: mkMediaWiki(bwikiGachaPayload),
2842
+ hsr: mkMediaWiki(bwikiGachaPayload),
2843
+ zzz: (url, signal) => fetchZzzGacha(url, signal),
2844
+ "zzz-bwiki": mkMediaWiki(pickCurrent(parseAllBwiki)),
2845
+ arknights: (url, signal) => fetchArknightsGacha(url, signal),
2846
+ "arknights-prts": mkMediaWiki(selectArknights),
2847
+ wuwa: (url, signal) => fetchWuwaGacha(url, signal),
2848
+ "wuwa-bwiki": mkMediaWiki(parseWuwaPool),
2849
+ // 终末地默认:Canmoe(中文,Next.js 数据经 host 代理两步抓取)
2850
+ endfield: (url, signal) => fetchCanmoeEndfield(url),
2851
+ // 终末地备选:GachaTracker(英文,浏览器直连)/ wiki.gg(英文,经 host 代理)
2852
+ "endfield-gachatracker": mkRaw(parseGachaTracker),
2853
+ "endfield-wiki-gg": (url, signal) => fetchEndfieldWikiGg(url),
2854
+ // 异环:ldshop(繁体,静态表格经 host 代理)
2855
+ nte: (url, signal) => fetchNteWanmei(url, signal),
2856
+ "nte-ldshop": (url, signal) => fetchLdshopNte(url),
2857
+ "ba-cn": (url, signal) => fetchBaCn(url),
2858
+ "ba-global": (url, signal) => fetchBaGlobal(url, signal),
2859
+ "ba-global-gamekee": () => fetchGameKeeBa("global"),
2860
+ "ba-jp": (url, signal) => fetchBaJpGacha(url, signal),
2861
+ "ba-jp-gamekee": () => fetchGameKeeBa("jp"),
2862
+ "r1999": (url, signal) => fetchR99(url, signal)
2863
+ };
2864
+ // 活动源注册表:条目 → { 默认 + 备选抓取器 }。没有独立活动源的条目活动来源显示"未配置"。
2865
+ const EVENT_FETCHERS = {
2866
+ // 原神:活动一览为 JS 动态加载(Dquery+SMW),走 SMW ask 查询(fetchYsActivity 忽略 URL 参数)
2867
+ genshin: {
2868
+ default: (url, signal) => fetchYsActivity(signal)
2869
+ },
2870
+ // 星铁:活动一览(静态「活动时间」表,api.php 可直连)→ 外显当期 + 悬停列出全部并行活动
2871
+ hsr: {
2872
+ default: mkMediaWiki(genericEventPayload)
2873
+ },
2874
+ // 绝区零:活动一览(静态「活动时间」表,api.php 可直连)→ 同上
2875
+ zzz: {
2876
+ default: mkMediaWiki(genericEventPayload)
2877
+ },
2878
+ // 明日方舟:PRTS 活动一览(「活动开始时间」表 + data-time 起止时间戳)→ 同上
2879
+ arknights: {
2880
+ default: mkMediaWiki(prtsEventPayload)
2881
+ },
2882
+ // 终末地:FZ Wiki(中文,经 host 代理、抓 RSC 数据;外显当期=结束最晚,悬停列出全部并行)
2883
+ endfield: {
2884
+ default: (url, signal) => fetchFzWikiEndfield(url).then((d) =>
2885
+ d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null
2886
+ ),
2887
+ "endfield-game8": (url, signal) => fetchGame8Endfield(url).then((d) =>
2888
+ d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "" } : null
2889
+ )
2890
+ },
2891
+ // 鸣潮:活动日历页(独立 eventUrl 源)→ 外显当期 + 悬停列出全部并行活动
2892
+ wuwa: {
2893
+ default: mkMediaWiki((html) => {
2894
+ const d = parseWuwaCalendar(html);
2895
+ return d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null;
2896
+ })
2897
+ },
2898
+ // 蔚蓝国服:默认与卡池同 URL(维护公告含活动名),也可独立配置其他来源
2899
+ "ba-cn": {
2900
+ default: (url, signal) => fetchBaCn(url)
2901
+ },
2902
+ // 蔚蓝国际服:默认与卡池同 URL(更新日誌含活动排期);备选 GameKee
2903
+ "ba-global": {
2904
+ default: (url, signal) => fetchBaGlobal(url, signal),
2905
+ "ba-global-gamekee": () => fetchGameKeeBa("global")
2906
+ },
2907
+ // 蔚蓝日服:活动源与卡池源独立——默认 GameKee 当期活动条目(原行为不变);
2908
+ // 备选 = 日服官方公告里的イベント条目(抓取器复用官方解析,仅取 event/eventDates)
2909
+ "ba-jp": {
2910
+ default: () => fetchGameKeeBa("jp"),
2911
+ "ba-jp-official": (url, signal) => fetchBaJpOfficialEvent(url, signal)
2912
+ },
2913
+ // 重返未来:默认与卡池同 URL(维护公告含活动)
2914
+ "r1999": {
2915
+ default: (url, signal) => fetchR99(url, signal)
2916
+ }
2917
+ };
2918
+
2919
+ // —— 备选源(altSources / eventAltSources)的字段约定与 altSourceId / normalizeSourceId,
2920
+ // 以及下面的 gachaFetcherFor / eventFetcherFor 用到的标识归一,都在 60-helpers.js ——
2921
+ // (设置页也要用这两个纯函数,所以放在 core 与外壳共用的 helpers 里,而不是 core 内部)
2922
+ // 取条目当前卡池源的抓取器:命中的备选源 > 默认抓取器(无 → null)
2923
+ function gachaFetcherFor(source, url) {
2924
+ const want = normalizeSourceId(url);
2925
+ const alt = (source.altSources || []).find((a) => altSourceId(a) === want && GACHA_FETCHERS[a.fetcher]);
2926
+ return alt ? GACHA_FETCHERS[alt.fetcher] : GACHA_FETCHERS[source.id] || null;
2927
+ }
2928
+
2929
+ // 取条目当前活动源的抓取器:命中的活动备选源 > 默认活动抓取器(无 → null)
2930
+ function eventFetcherFor(source, url) {
2931
+ const table = EVENT_FETCHERS[source.id];
2932
+ if (!table) return null;
2933
+ const want = normalizeSourceId(url);
2934
+ const alt = (source.eventAltSources || []).find((a) => altSourceId(a) === want && table[a.fetcher]);
2935
+ return alt ? table[alt.fetcher] : table.default || null;
2936
+ }
2937
+ //#endregion
2938
+
2939
+ //#region refresh
2940
+ // 两侧各自只允许写自己的字段:某些来源的载荷同时含卡池与活动字段(如 GameKee),
2941
+ // 若把整对象直接合并,活动源的数据会污染卡池列(反之亦然)——解耦契约的一部分。
2942
+ const GACHA_FIELDS = ["banner", "roles", "bannerDates", "bannerDatesRaw", "bannerHover"];
2943
+ const EVENT_FIELDS = ["event", "eventDates", "eventDatesRaw", "eventHover"];
2944
+ function pickFields(src, fields) {
2945
+ const out = {};
2946
+ if (!src) return out;
2947
+ for (const k of fields) if (src[k] !== void 0) out[k] = src[k];
2948
+ return out;
2949
+ }
2950
+ // 抓取单侧字段:自带解析器优先;**用户自己填的地址**(自定义条目 / 自带条目的自定义网址)
2951
+ // 才允许通用解析兜底——内置默认源不兜底:代理源直连必被 CORS 拦,那个错误不该算到来源头上。
2952
+ // 返回 { data, fail }:fail=null 表示该侧成功,否则 { kind: "down"|"nomatch", reason }。
2953
+ // data 一律原样带回(未命中时也可能附带可供同 URL 复用的其它字段)。
2954
+ async function resolveSide(side, { fetcher, url, allowGeneric, signal }) {
2955
+ const emptyOf = (d) => (side === "gacha" ? !d || !d.banner : !d || !d.event);
2956
+ const parseGeneric = () => (side === "gacha"
2957
+ ? tryParseGenericGacha(url, signal)
2958
+ : tryParseGenericEvent(url, signal));
2959
+ let data = null;
2960
+ let fail = null;
2961
+ if (fetcher) {
2962
+ try {
2963
+ data = await fetcher(url, signal);
2964
+ } catch (err) {
2965
+ if (err && err.name === "AbortError") throw err;
2966
+ fail = { kind: "down", reason: normErr(err) };
2967
+ }
2968
+ }
2969
+ if (emptyOf(data) && (!fetcher || allowGeneric)) {
2970
+ try {
2971
+ const g = await parseGeneric();
2972
+ if (!emptyOf(g)) { data = g; fail = null; } // 兜底成功 → 该侧按成功算
2973
+ } catch (err) {
2974
+ if (err && err.name === "AbortError") throw err;
2975
+ if (!fail) fail = { kind: "down", reason: normErr(err) };
2976
+ }
2977
+ }
2978
+ if (emptyOf(data)) return { data: data || null, fail: fail || { kind: "nomatch" } };
2979
+ return { data, fail: null };
2980
+ }
2981
+ // 抓取单个条目:卡池字段与活动字段分别由各自来源产出(见"统一来源注册表"契约)。
2982
+ // 两个来源独立选择:同 URL 且载荷已带活动字段 → 单次请求复用;否则各自独立抓取。
2983
+ // 返回 { ok, data, gachaFail, eventFail }:ok = 两侧都没有 down(只有 nomatch 仍算 ok)。
2984
+ async function fetchEntry(source, signal) {
2985
+ // 无任何来源的条目:跳过(不计成功也不计失败)
2986
+ if (!source.url && !source.eventUrl) return { ok: true, reason: "skipped" };
2987
+ // 来源标识统一归一(老配置的 proxy:/gk-*: 写法 → 真实地址 / 内部来源 id):
2988
+ // 抓取器一律只拿到"干净地址",不再需要在抓取层剥前缀
2989
+ const gachaUrl = normalizeSourceId(source.url);
2990
+ const eventUrl = normalizeSourceId(source.eventUrl);
2991
+ try {
2992
+ let g = { data: null, fail: null };
2993
+ if (source.url) {
2994
+ g = await resolveSide("gacha", {
2995
+ fetcher: gachaFetcherFor(source, gachaUrl),
2996
+ url: gachaUrl,
2997
+ allowGeneric: !!(source.custom || source.allowGenericGacha),
2998
+ signal
2999
+ });
3000
+ }
3001
+ let evData = null;
3002
+ let eventFail = null;
3003
+ if (source.eventUrl) {
3004
+ if (eventUrl === gachaUrl && g.data && g.data.event) {
3005
+ // 同 URL:活动字段就在卡池载荷里,不重复抓
3006
+ evData = {
3007
+ event: g.data.event,
3008
+ eventDates: g.data.eventDates || "",
3009
+ eventDatesRaw: g.data.eventDatesRaw || g.data.bannerDatesRaw || "",
3010
+ eventHover: g.data.eventHover || ""
3011
+ };
3012
+ } else {
3013
+ const ev = await resolveSide("event", {
3014
+ fetcher: eventFetcherFor(source, eventUrl),
3015
+ url: eventUrl,
3016
+ allowGeneric: !!(source.custom || source.allowGenericEvent),
3017
+ signal
3018
+ });
3019
+ evData = ev.data;
3020
+ eventFail = ev.fail;
3021
+ }
3022
+ }
3023
+ const data = pickFields(g.data, GACHA_FIELDS);
3024
+ if (evData) Object.assign(data, pickFields(evData, EVENT_FIELDS));
3025
+ const gachaFail = g.fail;
3026
+ return {
3027
+ ok: !isDown(gachaFail) && !isDown(eventFail),
3028
+ reason: "ok",
3029
+ data,
3030
+ gachaFail,
3031
+ eventFail
3032
+ };
3033
+ } catch (err) {
3034
+ // 顶层异常(含 12 秒超时中断):两侧按"报错"记,面板照实提示
3035
+ const aborted = !!err && err.name === "AbortError";
3036
+ const reason = aborted ? "超时" : normErr(err);
3037
+ const fail = { kind: "down", reason };
3038
+ return {
3039
+ ok: false,
3040
+ reason,
3041
+ gachaFail: source.url ? fail : null,
3042
+ eventFail: source.eventUrl ? fail : null
3043
+ };
3044
+ }
3045
+ }
3046
+
3047
+ // 合并本轮抓取结果与上次缓存(统一机制,不针对任何游戏):
3048
+ // - 展示字段按列兜底:本次某列没拿到就沿回旧值,并标记该列"显示的是旧值"(gachaStale/eventStale)
3049
+ // - 抓取状态(gachaFail/eventFail)永远来自本轮,既不继承也不删除
3050
+ // - okAt = 最近一次"两侧都拿到新数据"的时间;有任一侧没拿到就不刷新
3051
+ function mergeEntryRecord(r, prevRec, nowTs) {
3052
+ const gachaFail = (r && r.gachaFail) || null;
3053
+ const eventFail = (r && r.eventFail) || null;
3054
+ const rec = { ...((r && r.data) || {}), gachaFail, eventFail };
3055
+ if (prevRec) {
3056
+ if (!rec.banner && prevRec.banner) {
3057
+ rec.banner = prevRec.banner;
3058
+ rec.roles = prevRec.roles || "";
3059
+ if (!rec.bannerDates) rec.bannerDates = prevRec.bannerDates || "";
3060
+ if (!rec.bannerDatesRaw) rec.bannerDatesRaw = prevRec.bannerDatesRaw || prevRec.bannerDates || "";
3061
+ if (!rec.bannerHover) rec.bannerHover = prevRec.bannerHover || "";
3062
+ rec.gachaStale = true;
3063
+ }
3064
+ if (!rec.event && prevRec.event) {
3065
+ rec.event = prevRec.event;
3066
+ if (!rec.eventDates) rec.eventDates = prevRec.eventDates || "";
3067
+ if (!rec.eventDatesRaw) rec.eventDatesRaw = prevRec.eventDatesRaw || prevRec.eventDates || "";
3068
+ if (!rec.eventHover) rec.eventHover = prevRec.eventHover || "";
3069
+ rec.eventStale = true;
3070
+ }
3071
+ }
3072
+ rec.okAt = (!gachaFail && !eventFail) ? nowTs : (prevRec && prevRec.okAt) || 0;
3073
+ return rec;
3074
+ }
3075
+ async function refreshAll(entries, s) {
3076
+ const controller = new AbortController();
3077
+ const timeout = coreEnv.timer.setTimeout(() => controller.abort(), 12000);
3078
+ // 自定义爬取地址覆盖默认;克隆避免污染原始对象(卡池源+活动源分别覆盖)
3079
+ // allowGeneric*:只有"用户自己填的地址"(custom:<url>)或自定义条目才允许通用解析兜底
3080
+ const targets = entries.map((e) => ({
3081
+ ...e,
3082
+ url: getEntryUrl(s, e.id, e.url),
3083
+ eventUrl: getEntryUrl(s, e.id, e.eventUrl, "eventUrl"),
3084
+ allowGenericGacha: !!e.custom || isCustomSource(s, e.id),
3085
+ allowGenericEvent: !!e.custom || isCustomSource(s, e.id, "eventUrl")
3086
+ }));
3087
+ const results = await Promise.all(targets.map((t) => fetchEntry(t, controller.signal)));
3088
+ coreEnv.timer.clearTimeout(timeout);
3089
+ const okCount = results.filter((r) => r.ok).length;
3090
+ return {
3091
+ okCount,
3092
+ total: entries.length,
3093
+ at: nowMs(),
3094
+ status: okCount > 0 ? "ok" : "unreachable",
3095
+ results
3096
+ };
3097
+ }
3098
+ //#endregion
3099
+
3100
+ // —— 环境注入:把宿主给的三样东西接到 coreEnv(15-env.js)——
3101
+ // 必须放在 core 段之后:coreEnv 是 let 声明,提前调用会踩 TDZ。
3102
+ setCoreEnv({
3103
+ transport: engineEnv.transport,
3104
+ now: engineEnv.now || (() => Date.now()),
3105
+ timer: engineEnv.timer || {}
3106
+ });
3107
+
3108
+ // 抓取一轮:读配置 → 逐条抓取(卡池/活动各自独立)→ 与上次缓存按列合并 →
3109
+ // 写回缓存 → 返回 Result JSON(这就是"产品接口",各平台 UI 只读它)。
3110
+ async function refresh() {
3111
+ const s = await readSettings();
3112
+ // 用"全部条目"(含隐藏)抓取:隐藏再显示时立刻有数据,与既有行为一致
3113
+ const entries = getAllEntries(s);
3114
+ const result = await refreshAll(entries, s);
3115
+ const prev = await getCached();
3116
+ const games = {};
3117
+ entries.forEach((e, i) => {
3118
+ const rec = mergeEntryRecord(result.results[i], prev.games[e.id], nowMs());
3119
+ if (result.results[i].reason === "skipped") rec.skipped = true;
3120
+ // 统一成固定形状(内容字段缺失填空串)——见 engine-head.js 的 normalizeRecord
3121
+ games[e.id] = normalizeRecord(rec, e.name);
3122
+ });
3123
+ await storage.set("lastData", JSON.stringify(games));
3124
+ await storage.set("lastRefresh", result.at);
3125
+ await storage.set("lastSource", result.status === "ok" ? "web" : "none");
3126
+ return {
3127
+ schemaVersion: ENGINE_SCHEMA_VERSION,
3128
+ refreshedAt: result.at,
3129
+ parserVersions: parserVersionsOf(entries),
3130
+ games
3131
+ };
3132
+ }
3133
+
3134
+ // 解析器自检(交接文档 §13.4):对每个条目的两侧来源各跑一次,报告「解析出什么 / 报错原因」。
3135
+ // 用途:源站改版时快速定位「哪个源解析出 0 条、哪个源 403/超时」——各平台都能调用
3136
+ // (将来扩展里做「自检」按钮、CLI、CI 都行)。**只读**:不写缓存、不动设置。
3137
+ async function selfCheck(options) {
3138
+ const timeoutMs = (options && options.timeoutMs) || 12000;
3139
+ const s = await readSettings();
3140
+ const entries = getAllEntries(s);
3141
+ const targets = entries.map((e) => ({
3142
+ ...e,
3143
+ url: getEntryUrl(s, e.id, e.url),
3144
+ eventUrl: getEntryUrl(s, e.id, e.eventUrl, "eventUrl"),
3145
+ allowGenericGacha: !!e.custom || isCustomSource(s, e.id),
3146
+ allowGenericEvent: !!e.custom || isCustomSource(s, e.id, "eventUrl")
3147
+ }));
3148
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
3149
+ const timer = coreEnv.timer.setTimeout(() => { if (ac) ac.abort(); }, timeoutMs);
3150
+ let results;
3151
+ try {
3152
+ results = await Promise.all(targets.map((t) => fetchEntry(t, ac ? ac.signal : void 0)));
3153
+ } finally {
3154
+ coreEnv.timer.clearTimeout(timer);
3155
+ }
3156
+ // 一侧的结论:ok=有内容;nomatch=抓到页面但没当期内容(这才是"解析出 0 条");down=抓取/解析报错
3157
+ const describe = (kind, fail, data) => {
3158
+ const f = normalizeFail(fail);
3159
+ if (!f) {
3160
+ const text = kind === "gacha"
3161
+ ? [data.roles || data.banner, data.bannerDates].filter(Boolean).join(" / ")
3162
+ : [data.event, data.eventDates].filter(Boolean).join(" / ");
3163
+ return { state: "ok", reason: "", text: text || "(抓到了,但内容为空)" };
3164
+ }
3165
+ if (f.kind === "nomatch") return { state: "nomatch", reason: "", text: "解析出 0 条(源站无当期内容)" };
3166
+ return { state: "down", reason: f.reason || "抓取异常", text: "抓取失败:" + (f.reason || "抓取异常") };
3167
+ };
3168
+ const games = {};
3169
+ const summary = { ok: 0, nomatch: 0, down: 0 };
3170
+ entries.forEach((e, i) => {
3171
+ const r = results[i] || {};
3172
+ const d = r.data || {};
3173
+ const gacha = describe("gacha", r.gachaFail, d);
3174
+ const event = describe("event", r.eventFail, d);
3175
+ games[e.id] = { name: e.name, parserVersion: e.parserVersion, gacha, event };
3176
+ for (const side of [gacha, event]) summary[side.state]++;
3177
+ });
3178
+ const problems = [];
3179
+ for (const [id, v] of Object.entries(games)) {
3180
+ const bad = [];
3181
+ if (v.gacha.state !== "ok") bad.push("卡池 " + v.gacha.text);
3182
+ if (v.event.state !== "ok") bad.push("活动 " + v.event.text);
3183
+ if (bad.length) problems.push(`${v.name}(${id}): ${bad.join(";")}`);
3184
+ }
3185
+ return { at: nowMs(), total: entries.length, summary, problems, games };
3186
+ }
3187
+
3188
+ // 测试出口:给回归脚本用(DSH 产物里只在 globalThis.__DSH_GACHA_TEST__ 为真时对外暴露)
3189
+ const __test = {
3190
+ fetchEntry,
3191
+ resolveSide,
3192
+ mergeEntryRecord,
3193
+ buildScrapeInfo,
3194
+ entryFailParts,
3195
+ sideFailText,
3196
+ normalizeFail,
3197
+ normErr,
3198
+ isDown,
3199
+ isNomatch,
3200
+ refreshAll,
3201
+ gachaFetcherFor,
3202
+ eventFetcherFor,
3203
+ normalizeSourceId,
3204
+ altSourceId,
3205
+ getEntryUrl,
3206
+ isCustomSource,
3207
+ readSettings,
3208
+ getCached,
3209
+ listGames,
3210
+ GACHA_FETCHERS,
3211
+ EVENT_FETCHERS,
3212
+ SOURCES,
3213
+ DEFAULT_SETTINGS
3214
+ };
3215
+
3216
+ return {
3217
+ schemaVersion: ENGINE_SCHEMA_VERSION,
3218
+ listGames,
3219
+ getCached,
3220
+ refresh,
3221
+ selfCheck,
3222
+ __test
3223
+ };
3224
+ }
3225
+ //#endregion