gacha-calendar-core 0.10.6 → 0.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core.mjs +368 -86
- package/package.json +1 -1
package/core.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 用途:缓存里记录"这份数据是哪版插件产出的"。更新插件后首次启动,据此**强制**刷新一次
|
|
5
5
|
// (不看自动刷新开关)——因为有些改动(悬停格式、来源地址、样式、解析器)不刷新就看不到效果。
|
|
6
6
|
// 写入时机见 engine-api.js 的 refresh();判定见 60-helpers.js 的 autoRefreshPlan()。
|
|
7
|
-
const PLUGIN_VERSION = "0.10.
|
|
7
|
+
const PLUGIN_VERSION = "0.10.8";
|
|
8
8
|
//#endregion
|
|
9
9
|
|
|
10
10
|
//#region config
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
// 其中 recommend 组含逐期「角色/武器活动唤取」公告,正文带 ✦活动时间✦ 起止
|
|
62
62
|
const WUWA_NOTICE_ENTRY = "https://aki-gm-resources-back.aki-game.com/gamenotice/G152/76402e5b20be2c39f095a152090afddc/entrypoint.json";
|
|
63
63
|
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";
|
|
64
|
+
// 鸣潮 Bwiki 活动日历页(已从**默认活动源降级为备选**,2026-10-01):
|
|
65
|
+
// 该页仍可解析(parseWuwaCalendar 能抽到 11 条),但**已停更**——最新一条结束于 2026/9/29,
|
|
66
|
+
// 没有当期 3.7 的活动,所以不改解析器,只把它挪到备选。
|
|
67
|
+
const WUWA_EVENT_BWIKI_URL = "https://wiki.biligame.com/wutheringwaves/api.php?action=parse&page=%E9%A6%96%E9%A1%B5%2F%E6%B4%BB%E5%8A%A8%E6%97%A5%E5%8E%86&prop=text&format=json&formatversion=2";
|
|
64
68
|
// 绝区零官网公告(api-takumi-static content_v2_user,经 host 代理;无 CORS、无需登录):
|
|
65
69
|
// iChanId=279(公告频道)返回逐条公告正文(sIntro/sContent),其中「X.Y版本限时频段(上/下期)」
|
|
66
70
|
// 含该期精确起止 + 限定 S 级代理人/音擎;起点写「版本更新后」时取同版本「更新公告」的 dtStartTime。
|
|
@@ -128,19 +132,27 @@
|
|
|
128
132
|
},
|
|
129
133
|
{
|
|
130
134
|
id: "wuwa",
|
|
131
|
-
parserVersion:
|
|
135
|
+
parserVersion: 2,
|
|
132
136
|
name: "鸣潮",
|
|
133
137
|
icon: "https://storage.moegirl.org.cn/moegirl/commons/2/29/WutheringWavesIcon.png!/fw/64",
|
|
134
138
|
source: "官方公告",
|
|
135
|
-
// 默认卡池源=官方公告(aki-gm-resources-back,经 host
|
|
139
|
+
// 默认卡池源=官方公告(aki-gm-resources-back,经 host 代理):
|
|
140
|
+
// entrypoint.json → <dir>/zh-Hans.json,其 `recommend` 组里 tag=7 为逐期「角色/武器活动唤取」,
|
|
141
|
+
// 条目自带 `startTimeMs`/`endTimeMs` 绝对时间戳(不再解析正文,见 parseWuwaNotice)。
|
|
136
142
|
// 无当期公告或抓取失败时由抓取器自动回退 Bwiki 角色轮换池;也可在设置中手动切 Bwiki(备选)
|
|
137
143
|
url: WUWA_NOTICE_ENTRY,
|
|
138
144
|
altSources: [
|
|
139
145
|
{ label: "Bwiki 角色轮换池", url: WUWA_BWIKI_URL, fetcher: "wuwa-bwiki" }
|
|
140
146
|
],
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
// 活动源:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动,见 parseWuwaRecommendEvents),
|
|
148
|
+
// 与卡池同址 → 一次请求复用两侧数据。
|
|
149
|
+
// ⚠️ 2026-10-01 改:Bwiki 活动日历页**已停更**(最新一条结束于 2026/9/29,无 3.7 内容),
|
|
150
|
+
// 所以把它从默认**降级为备选**(eventAltSources),仍可在设置里手动切回。
|
|
151
|
+
eventUrl: WUWA_NOTICE_ENTRY,
|
|
152
|
+
eventSource: "官方公告",
|
|
153
|
+
eventAltSources: [
|
|
154
|
+
{ label: "Bwiki 活动日历", url: WUWA_EVENT_BWIKI_URL, fetcher: "wuwa-event-bwiki" }
|
|
155
|
+
]
|
|
144
156
|
},
|
|
145
157
|
{
|
|
146
158
|
id: "arknights",
|
|
@@ -1165,17 +1177,36 @@ export function createEngine(env) {
|
|
|
1165
1177
|
|
|
1166
1178
|
// 选当期:优先"起始明确且覆盖 now"的主池;
|
|
1167
1179
|
// 其次"起始未知(版本更新后)但结束在未来"的主池(星铁/zzz 上半,此时起始已被 fillMissingStarts 补齐);
|
|
1168
|
-
//
|
|
1180
|
+
// **当期多个主池的角色全部合并**(见下方合并说明)。返回 null 时调用方回退内置数据。
|
|
1169
1181
|
// bannerDates 为补全后用于倒计时的文本;bannerDatesRaw 为源站原文(悬停展示)。
|
|
1170
1182
|
function selectCurrent(items, now) {
|
|
1183
|
+
// 先记下**补全前**哪些行的起点是"版本更新后"(startTs 原本为 null)。
|
|
1184
|
+
// 为什么要记:fillMissingStarts 会用"上一组结束时间"去猜这类行的起点,
|
|
1185
|
+
// 而那个猜测**并非总是对的** —— 星铁 4.6 上线时间期有两池:
|
|
1186
|
+
// 「韶艾裁英」4.6版本更新后 ~ 10-21 11:59
|
|
1187
|
+
// 「沧海萃珠」4.6版本更新后 ~ 11-10 15:00
|
|
1188
|
+
// 两者同为"4.6 上线",只是结束不同;按"上一组结束"补会把后者补成 10-21,
|
|
1189
|
+
// 于是"起点已过"判定失败、它被踢出当期 —— 而它其实覆盖当前时刻
|
|
1190
|
+
// (用户实测:卡片只显示绯英,真珠不见了)。
|
|
1191
|
+
const hadOpenStart = new Set(items.filter((it) => it.startTs == null).map((it) => it));
|
|
1171
1192
|
fillMissingStarts(items);
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1193
|
+
const inWindow = (it) => it.endTs != null && it.endTs >= now
|
|
1194
|
+
&& (it.startTs == null ? true : it.startTs <= now || hadOpenStart.has(it));
|
|
1195
|
+
// 说明最后一个条件:起点原本为 null(写作"版本更新后")的行,**一定已经开始**——
|
|
1196
|
+
// 源站表只列**已发布版本**的排期,"X.Y版本更新后"里的 X.Y 必已上线,
|
|
1197
|
+
// 所以它的真实起点在版本更新那天(≤ now),补出来的那个值不可信、不应用来判"未开始"。
|
|
1198
|
+
// 注意这里**不覆盖**补全后的 startTs:显示(bannerDates 等)仍用补出来的窗口,
|
|
1199
|
+
// 悬停也仍显示源站原文"4.6版本更新后 ~ …",不编造日期。
|
|
1200
|
+
const pool = items.filter(inWindow).filter((it) => it.isMain);
|
|
1175
1201
|
if (pool.length === 0) return null;
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1202
|
+
// 外显取哪个池的"名字与时间":结束最早的(越快结束越该被盯住)。
|
|
1203
|
+
//
|
|
1204
|
+
// ⚠️ 合并条件(用户要求,2026-09-30 改):
|
|
1205
|
+
// 旧 = 起止**完全相同**才合并 → 星铁同期两池结束不同就只合到自己,
|
|
1206
|
+
// 卡片只显示一个池的角色。
|
|
1207
|
+
// 新 = **当前时刻落在其持续区间内的主池全部合并**(就是这个 `pool`)。
|
|
1208
|
+
const first = pool.slice().sort((a, b) => a.endTs - b.endTs)[0];
|
|
1209
|
+
const roles = [...new Set(pool.map((it) => cleanRoles(it.roles)).filter(Boolean))].join("、");
|
|
1179
1210
|
return {
|
|
1180
1211
|
banner: first.banner,
|
|
1181
1212
|
roles,
|
|
@@ -1365,8 +1396,13 @@ export function createEngine(env) {
|
|
|
1365
1396
|
return null;
|
|
1366
1397
|
}
|
|
1367
1398
|
|
|
1368
|
-
// 鸣潮官方公告解析:从全量公告(game/activity/recommend)中取"覆盖当前时刻"
|
|
1369
|
-
//
|
|
1399
|
+
// 鸣潮官方公告解析:从全量公告(game/activity/recommend)中取"覆盖当前时刻"的「角色活动唤取」。
|
|
1400
|
+
//
|
|
1401
|
+
// ⚠️ 时间**必须用 JSON 里的绝对时间戳** `startTimeMs` / `endTimeMs`,不要再去解析正文。
|
|
1402
|
+
// 正文写的是 `✦活动时间✦ 3.7版本更新后 ~ 2026年10月22日09:59(服务器时间)` ——
|
|
1403
|
+
// 起始端是**版本标签**而非绝对日期,旧实现用 `(\d{4})年(\d{1,2})月…` 匹配整段 → 恒失败 →
|
|
1404
|
+
// 卡池侧返回 null(2026-10-01 实测:官方 JSON 已带 3 个在开角色池,却显示"未公布")。
|
|
1405
|
+
// 现在源站直接给了时间戳,比解析正文更准,也不受措辞漂移影响。
|
|
1370
1406
|
function parseWuwaNotice(list, now = nowMs()) {
|
|
1371
1407
|
const groups = [list?.game, list?.activity, list?.recommend].filter(Array.isArray);
|
|
1372
1408
|
const stripH = (s) => String(s || "")
|
|
@@ -1377,41 +1413,88 @@ export function createEngine(env) {
|
|
|
1377
1413
|
const pools = [];
|
|
1378
1414
|
for (const arr of groups) {
|
|
1379
1415
|
for (const it of arr) {
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
const
|
|
1383
|
-
const
|
|
1384
|
-
if (!
|
|
1385
|
-
const sTs =
|
|
1386
|
-
|
|
1416
|
+
// 标题可能是 "[但愿长圆如此夜]\n角色活动唤取" / "「玉阙玄华」武器活动唤取"
|
|
1417
|
+
const title = stripH(it.tabTitle || it.title || "").replace(/\s+/g, " ").trim();
|
|
1418
|
+
const isChar = /角色活动唤取/.test(title);
|
|
1419
|
+
const isWeapon = /武器活动唤取/.test(title);
|
|
1420
|
+
if (!isChar && !isWeapon) continue;
|
|
1421
|
+
const sTs = Number(it.startTimeMs), eTs = Number(it.endTimeMs);
|
|
1422
|
+
if (!Number.isFinite(sTs) || !Number.isFinite(eTs)) continue;
|
|
1387
1423
|
if (sTs > now || eTs < now) continue; // 只要当期覆盖
|
|
1388
1424
|
const name = title
|
|
1389
1425
|
.replace(/\s*(?:角色|武器)活动唤取\s*$/, "")
|
|
1390
1426
|
.replace(/^[\[【「]\s*/, "")
|
|
1391
1427
|
.replace(/\s*[\]】」]$/, "")
|
|
1392
1428
|
.trim();
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1429
|
+
if (!name) continue;
|
|
1430
|
+
// 类型名(外显用):标题尾部那一段,如 `角色活动唤取` / `武器活动唤取`
|
|
1431
|
+
const type = isChar ? "角色活动唤取" : "武器活动唤取";
|
|
1432
|
+
// 角色名:正文开头那句「活动期间,5星角色「心」,4星角色「卜灵」、「桃祈」、「釉瑚」唤取概率限时提升!」
|
|
1433
|
+
// 用 [^。!?\n]+ 限在一句内,避免把后面「唤取说明」里的角色也带进来
|
|
1434
|
+
const text = stripH(it.content || "");
|
|
1435
|
+
const upM = text.match(/活动期间,([^。!?\n]+?)唤取概率限时提升/);
|
|
1436
|
+
const roles = upM
|
|
1437
|
+
? [...new Set([...upM[1].matchAll(/[「【]([^」】]+)[」】]/g)].map((x) => x[1].trim()).filter(Boolean))].join("、")
|
|
1438
|
+
: "";
|
|
1439
|
+
pools.push({ name, type, isChar, roles, startTs: sTs, endTs: eTs });
|
|
1396
1440
|
}
|
|
1397
1441
|
}
|
|
1398
1442
|
const cur = pools.filter((p) => p.isChar && p.name);
|
|
1399
1443
|
if (cur.length === 0) return null;
|
|
1400
|
-
|
|
1401
|
-
const
|
|
1444
|
+
// 外显与窗口取**结束最早**的那个池(与 selectCurrent 的 first 同口径)。
|
|
1445
|
+
const first = cur.slice().sort((a, b) => a.endTs - b.endTs)[0];
|
|
1446
|
+
// 角色:**先拆成单个名字再去重**,然后合并。
|
|
1447
|
+
// ⚠️ 别写成 `new Set(cur.map(p => p.roles))` —— 那样比较的是"整串"(各池的 4★ 名单相同
|
|
1448
|
+
// 但 5★ 不同 → 整串不同 → 重复留下)。拆开才能把三池共有的 4★ 去成一份。
|
|
1449
|
+
// 顺序 = 各池依次展开(5★ 在前、4★ 共有名在后),去重后即 `心、千咲、尤诺、卜灵、桃祈、釉瑚`。
|
|
1450
|
+
const roles = [...new Set(cur.flatMap((p) => p.roles.split("、").map((s) => s.trim()).filter(Boolean)))].join("、");
|
|
1402
1451
|
const fmt = (ts) => {
|
|
1403
1452
|
const d = new Date(ts);
|
|
1404
1453
|
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")}`;
|
|
1405
1454
|
};
|
|
1406
1455
|
const raw = fmtWindow(first.startTs, first.endTs);
|
|
1407
|
-
//
|
|
1408
|
-
|
|
1456
|
+
// 悬停:首行=卡池类型(与卡片外显同源),随后每池"池名:角色"一行;窗口相同则时间只在末尾写一遍;
|
|
1457
|
+
// 按结束时间升序。**类型行只在本源补**,不动全站共用的 buildPoolHover(它按约定对 0/1 池返回 "")。
|
|
1458
|
+
const poolHover = buildPoolHover(cur.map((p) => ({
|
|
1409
1459
|
name: p.name,
|
|
1410
1460
|
label: `${p.name}\uFF1A${p.roles || "-"}`,
|
|
1411
1461
|
startTs: p.startTs,
|
|
1412
1462
|
endTs: p.endTs
|
|
1413
1463
|
})));
|
|
1414
|
-
|
|
1464
|
+
const bannerHover = poolHover ? `${first.type}\n${poolHover}` : "";
|
|
1465
|
+
// **外显用卡池类型**(`角色活动唤取`),不用某一个池名:
|
|
1466
|
+
// 角色是多池合并的,若外显挂"但愿长圆如此夜",卡片就成了"标题只说一个池、角色却是三个池的合成"。
|
|
1467
|
+
// 与用户 2026-09-30 定的统一口径一致(国服显示 `限时限定招募`、异环显示 `限定棋盘`)。
|
|
1468
|
+
// 具体池名在悬停里逐条列出,不丢信息。
|
|
1469
|
+
return { banner: first.type, roles, bannerDates: raw, bannerDatesRaw: raw, startTs: first.startTs, endTs: first.endTs, bannerHover };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// 鸣潮官方活动解析:同一份全量公告的 `recommend` 组里,`tag === 7` 是卡池、**`tag === 5` 是限时活动**。
|
|
1473
|
+
// 活动条目形如 tabTitle="[团团勇者大乱斗]休闲活动",同样带绝对时间戳。
|
|
1474
|
+
// 这是 2026-10-01 起鸣潮活动的**默认源**——Bwiki 活动日历页已停更(最新一条结束于 2026/9/29),
|
|
1475
|
+
// 而官方源有当期 3.7 的活动,且与卡池同源、同一次请求即可拿到两侧数据。
|
|
1476
|
+
function parseWuwaRecommendEvents(list, now = nowMs()) {
|
|
1477
|
+
const arr = Array.isArray(list?.recommend) ? list.recommend : [];
|
|
1478
|
+
const stripH = (s) => String(s || "")
|
|
1479
|
+
.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ")
|
|
1480
|
+
.replace(/ /g, " ").replace(/&/g, "&")
|
|
1481
|
+
.replace(/[ \t]+/g, " ").replace(/\n\s*\n+/g, "\n").trim();
|
|
1482
|
+
const events = [];
|
|
1483
|
+
for (const it of arr) {
|
|
1484
|
+
if (Number(it.tag) !== 5) continue; // 7=卡池;5=限时活动
|
|
1485
|
+
const name = stripH(it.tabTitle || "").replace(/\s+/g, " ").trim();
|
|
1486
|
+
if (!name) continue;
|
|
1487
|
+
const sTs = Number(it.startTimeMs), eTs = Number(it.endTimeMs);
|
|
1488
|
+
if (!Number.isFinite(sTs) || !Number.isFinite(eTs)) continue;
|
|
1489
|
+
if (sTs > now || eTs < now) continue;
|
|
1490
|
+
events.push({ name, cat: "", startTs: sTs, endTs: eTs });
|
|
1491
|
+
}
|
|
1492
|
+
if (events.length === 0) return null;
|
|
1493
|
+
const sorted = sortEventItems(events);
|
|
1494
|
+
if (sorted.length === 0) return null;
|
|
1495
|
+
const primary = pickEventPrimary(sorted) || sorted[0];
|
|
1496
|
+
const dates = fmtWindow(primary.startTs, primary.endTs);
|
|
1497
|
+
return { banner: primary.name, bannerDates: dates, bannerDatesRaw: dates, eventHover: buildEventHover(sorted) };
|
|
1415
1498
|
}
|
|
1416
1499
|
|
|
1417
1500
|
// 鸣潮卡池默认抓取器:官方公告(entrypoint → 目录 → zh-Hans.json 全量)优先;
|
|
@@ -1425,8 +1508,18 @@ export function createEngine(env) {
|
|
|
1425
1508
|
let list = null;
|
|
1426
1509
|
try { list = await proxyFetchJson(dir + "zh-Hans.json", ref); } catch { list = null; }
|
|
1427
1510
|
if (!list || typeof list !== "object") list = await proxyFetchJson(dir + "notice.json", ref);
|
|
1511
|
+
// **同一份 JSON 里卡池(tag=7)与活动(tag=5)都在** → 顺带把活动字段也返回。
|
|
1512
|
+
// 这是"统一来源注册表"里写明的同址复用契约:卡池载荷带 event 字段时,
|
|
1513
|
+
// refresh 不再为活动侧另抓一次(否则同址条目每轮多一个请求)。
|
|
1514
|
+
// ⚠️ 我重写解析时一度只返回卡池字段,导致鸣潮每轮请求从 1 次变成 2 次 —— 已补回。
|
|
1515
|
+
// 活动侧仍保留自己的抓取器(fetchWuwaEventsOfficial):卡池侧失败、或用户在设置里
|
|
1516
|
+
// 单独选活动来源时,活动侧要能自己抓、自己报错(解耦不变)。
|
|
1517
|
+
const ev = parseWuwaRecommendEvents(list, now);
|
|
1518
|
+
const evFields = ev
|
|
1519
|
+
? { event: ev.banner, eventDates: ev.bannerDates || "", eventDatesRaw: ev.bannerDatesRaw || ev.bannerDates || "", eventHover: ev.eventHover || "" }
|
|
1520
|
+
: {};
|
|
1428
1521
|
const d = parseWuwaNotice(list, now);
|
|
1429
|
-
if (d) return d;
|
|
1522
|
+
if (d) return { ...d, ...evFields };
|
|
1430
1523
|
} catch { /* 官方失败 → Bwiki 备选 */ }
|
|
1431
1524
|
const apiUrl = WUWA_BWIKI_URL + (WUWA_BWIKI_URL.includes("?") ? "&" : "?") + "origin=*";
|
|
1432
1525
|
const res = await transportFetchRaw(apiUrl, { signal, headers: rawHeaders(apiUrl) });
|
|
@@ -1437,6 +1530,25 @@ export function createEngine(env) {
|
|
|
1437
1530
|
return parseWuwaPool(html);
|
|
1438
1531
|
}
|
|
1439
1532
|
|
|
1533
|
+
// 鸣潮活动默认抓取器:抓同一份官方公告,取 `recommend` 组里 tag=5 的限时活动。
|
|
1534
|
+
// 与卡池侧同一 URL(entrypoint.json),所以 refresh 的同址复用会让两侧共用一次请求。
|
|
1535
|
+
async function fetchWuwaEventsOfficial(entryUrl) {
|
|
1536
|
+
const ref = "https://aki-gm-resources.aki-game.com/";
|
|
1537
|
+
const ej = await proxyFetchJson(entryUrl, ref);
|
|
1538
|
+
const contentUrl = Array.isArray(ej?.contentUrl) ? ej.contentUrl[0] : "";
|
|
1539
|
+
const dir = contentUrl ? contentUrl.replace(/[^/]*$/, "") : String(entryUrl).replace(/[^/]*$/, "");
|
|
1540
|
+
const list = await proxyFetchJson(dir + "zh-Hans.json", ref);
|
|
1541
|
+
if (!list || typeof list !== "object") throw new Error("wuwa-event-bad-json");
|
|
1542
|
+
const d = parseWuwaRecommendEvents(list, nowMs());
|
|
1543
|
+
if (!d) return null;
|
|
1544
|
+
return {
|
|
1545
|
+
event: d.banner,
|
|
1546
|
+
eventDates: d.bannerDates || "",
|
|
1547
|
+
eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "",
|
|
1548
|
+
eventHover: d.eventHover || ""
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1440
1552
|
// 绝区零官网公告解析:从公告频道(iChanId=279)取「X.Y版本限时频段(上/下期)」,选覆盖当前时刻的一期。
|
|
1441
1553
|
// 公告形如:sIntro="本期代理人与音擎调频活动时间为:3.2版本更新后 ~ 2026/09/30 11:59",
|
|
1442
1554
|
// sContent 内含「活动期间,限定S级代理人[克拉蕾(电·锋御)]、[南宫羽(以太·击破)]…」。
|
|
@@ -1794,7 +1906,15 @@ export function createEngine(env) {
|
|
|
1794
1906
|
const d = new Date(iso);
|
|
1795
1907
|
return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1796
1908
|
};
|
|
1797
|
-
//
|
|
1909
|
+
// **统一规则(§49)**:当前时刻落在持续期间内的卡池**全部合并**外显。
|
|
1910
|
+
// 旧实现是"两个分支各自命中第一个就 return" → 只显示一个池,同期的另一个池看不到
|
|
1911
|
+
// (用户实测:终末地外显没合并)。现在先把两类候选都收集起来再合并。
|
|
1912
|
+
//
|
|
1913
|
+
// ⚠️ 这条路径**不走 `selectCurrent`**(canmoe 的 chunk 是压缩 JS,得单独解析),
|
|
1914
|
+
// 所以 §49 改 `selectCurrent` 时漏了这里 —— 教训:改"通用规则"要**枚举所有外显路径**,
|
|
1915
|
+
// 不能只改名字最像的那个函数。
|
|
1916
|
+
const active = [];
|
|
1917
|
+
// 1) 当期 d:windows 覆盖当前 → 收进来
|
|
1798
1918
|
const curM = js.match(/\bd=\{(.+?)\},\s*u=\[/);
|
|
1799
1919
|
if (curM) {
|
|
1800
1920
|
sawStructure = true;
|
|
@@ -1803,10 +1923,12 @@ export function createEngine(env) {
|
|
|
1803
1923
|
const roles = featuredM ? featuredM[1].trim() : "";
|
|
1804
1924
|
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)) {
|
|
1805
1925
|
const a = new Date(w[1]).getTime(), b = new Date(w[2]).getTime();
|
|
1806
|
-
if (a <= now && now <= b)
|
|
1926
|
+
if (a <= now && now <= b) {
|
|
1927
|
+
active.push({ banner: `\u3010${w[3]}\u3011${roles}`, roles, startTs: a, endTs: b, dates: `${fmt(w[1])} ~ ${fmt(w[2])}`, raw: `${fmt(w[1])} ~ ${fmt(w[2])}` });
|
|
1928
|
+
}
|
|
1807
1929
|
}
|
|
1808
1930
|
}
|
|
1809
|
-
// 2) p 数组中的"当前进行中"
|
|
1931
|
+
// 2) p 数组中的"当前进行中"条目(过期当期后以此为兜底)—— 同样**全部收**,不是取第一个
|
|
1810
1932
|
const arr = extractCanmoePeriods(js);
|
|
1811
1933
|
if (arr) {
|
|
1812
1934
|
sawStructure = true;
|
|
@@ -1815,15 +1937,28 @@ export function createEngine(env) {
|
|
|
1815
1937
|
const pe = (e.match(/periodEnd\s*:\s*"([^"]*)"/) || [])[1];
|
|
1816
1938
|
if (!ps || !pe) continue;
|
|
1817
1939
|
const a = new Date(ps).getTime(), b = new Date(pe).getTime();
|
|
1818
|
-
if (a <= now && now <= b)
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1940
|
+
if (!(a <= now && now <= b)) continue;
|
|
1941
|
+
const title = (e.match(/title\s*:\s*"([^"]*)"/) || [])[1] || "";
|
|
1942
|
+
const subtitle = (e.match(/subtitle\s*:\s*"([^"]*)"/) || [])[1] || "";
|
|
1943
|
+
const version = (e.match(/version\s*:\s*"([^"]*)"/) || [])[1] || "";
|
|
1944
|
+
active.push({
|
|
1945
|
+
banner: title || `\u3010${version}\u3011${subtitle}`,
|
|
1946
|
+
roles: subtitle || title,
|
|
1947
|
+
startTs: a, endTs: b,
|
|
1948
|
+
dates: `${fmt(ps)} ~ ${fmt(pe)}`, raw: `${fmt(ps)} ~ ${fmt(pe)}`
|
|
1949
|
+
});
|
|
1825
1950
|
}
|
|
1826
1951
|
}
|
|
1952
|
+
if (active.length > 0) {
|
|
1953
|
+
// 外显名与窗口取**结束最早**的那个池(越快结束越该被盯住;与 selectCurrent 的 first 同口径)
|
|
1954
|
+
const first = active.slice().sort((x, y) => x.endTs - y.endTs)[0];
|
|
1955
|
+
return {
|
|
1956
|
+
banner: first.banner,
|
|
1957
|
+
roles: [...new Set(active.map((p) => p.roles).filter(Boolean))].join("、"),
|
|
1958
|
+
bannerDates: first.dates,
|
|
1959
|
+
bannerDatesRaw: first.raw
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1827
1962
|
// 结构在但没覆盖当前时刻 → null(未公布);连结构都没有 → undefined(页面改版,交上层决定)
|
|
1828
1963
|
return sawStructure ? null : void 0;
|
|
1829
1964
|
}
|
|
@@ -2034,7 +2169,64 @@ export function createEngine(env) {
|
|
|
2034
2169
|
return null;
|
|
2035
2170
|
}
|
|
2036
2171
|
|
|
2037
|
-
//
|
|
2172
|
+
// 异环公告里的「棋盘」条目解析(角色卡池)。
|
|
2173
|
+
//
|
|
2174
|
+
// 为什么从**「棋盘」**入手(用户建议,2026-09-30):
|
|
2175
|
+
// 异环的角色卡池在公告里叫「X」**限定棋盘**,条目形如
|
|
2176
|
+
// ● 全新限定S级角色「黑羽」
|
|
2177
|
+
// 开放时间:9月24日版本更新后-10月15日05:59
|
|
2178
|
+
// 棋盘说明:可通过「预言终幕时」限定棋盘获得S级角色「黑羽」。…
|
|
2179
|
+
// `棋盘说明` 是**角色卡池独有的锚点** —— 弧盘走 `研募说明`、剧情段没有这个字段。
|
|
2180
|
+
// 用「有棋盘说明」筛,比用"全新限定S级角色"精确:后者漏掉**返场**(`限定S级角色「安魂曲」返场`,
|
|
2181
|
+
// 没有"全新"二字),而那也是一张在开的角色池。
|
|
2182
|
+
//
|
|
2183
|
+
// 旧实现只认 `全新限定S级角色「X」…开放时间:N月N日**维护**更新后-…` 一条正则,
|
|
2184
|
+
// 而现公告写的是 `**版本**更新后` → 一条都匹配不上 → 卡池为空 → `if (!data.banner) return null`
|
|
2185
|
+
// → `fetchNteWanmei` 继续往下试,最终拿 index1 页那篇**已过期**的旧公告冒充当期。
|
|
2186
|
+
function parseNteBoards(text, nowYear) {
|
|
2187
|
+
const lines = text.split("\n").map((l) => l.trim());
|
|
2188
|
+
// 「一、 全新角色&弧盘」这一段的边界(只在这里找,避免匹配到别处的"开放时间")
|
|
2189
|
+
const start = lines.findIndex((l) => /^一、/.test(l));
|
|
2190
|
+
if (start < 0) return [];
|
|
2191
|
+
let end = lines.findIndex((l, i) => i > start && /^二、/.test(l));
|
|
2192
|
+
if (end < 0) end = lines.length;
|
|
2193
|
+
const pools = [];
|
|
2194
|
+
for (let i = start; i < end; i++) {
|
|
2195
|
+
const m = lines[i].match(/^●\s*(?:全新)?(限定S级角色|S级角色)「([^」]+)」(返场)?/);
|
|
2196
|
+
if (!m) continue;
|
|
2197
|
+
// 往后找该条目的「开放时间」与「棋盘说明」(各限 8 行内)
|
|
2198
|
+
let range = null, board = "";
|
|
2199
|
+
for (let j = i + 1; j < Math.min(end, i + 8); j++) {
|
|
2200
|
+
if (!range) {
|
|
2201
|
+
const t = lines[j].match(/^开放时间:(\d+)月(\d+)日(?:(?:维护|版本)更新后|(\d{1,2}):(\d{2}))\s*[-–—]\s*(\d+)月(\d+)日(\d{1,2}):(\d{2})/);
|
|
2202
|
+
if (t) {
|
|
2203
|
+
range = {
|
|
2204
|
+
sMo: +t[1], sD: +t[2], sH: t[3] ? +t[3] : 11, sMi: t[4] ? +t[4] : 0,
|
|
2205
|
+
eMo: +t[5], eD: +t[6], eH: +t[7], eMi: +t[8]
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (!board) {
|
|
2210
|
+
const b = lines[j].match(/棋盘说明:可通过「([^」]+)」限定棋盘获得/);
|
|
2211
|
+
if (b) board = b[1];
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
// **有棋盘说明才是角色卡池**(弧盘那条走研募说明,会在这里被排除)
|
|
2215
|
+
if (!range || !board) continue;
|
|
2216
|
+
pools.push({
|
|
2217
|
+
name: board,
|
|
2218
|
+
// 类型:`全新限定S级角色` → 限定棋盘;`限定S级角色…返场` → 返场限定棋盘。
|
|
2219
|
+
// 注意 `限定` 属于**类型的一部分**,不是动词/修饰(与国服"更新限时限定招募"同理)。
|
|
2220
|
+
type: `${m[3] ? "返场" : ""}限定棋盘`,
|
|
2221
|
+
roles: baRoleName(m[2]),
|
|
2222
|
+
startTs: new Date(nowYear, range.sMo - 1, range.sD, range.sH, range.sMi).getTime(),
|
|
2223
|
+
endTs: new Date(nowYear, range.eMo - 1, range.eD, range.eH, range.eMi).getTime()
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
return pools;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// 解析官网公告正文 → 当期卡池(有「棋盘说明」的角色卡池,按**统一规则**合并)+ 当期活动(限时活动)
|
|
2038
2230
|
function parseNteWanmei(html) {
|
|
2039
2231
|
const text = String(html || "")
|
|
2040
2232
|
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
@@ -2044,17 +2236,24 @@ export function createEngine(env) {
|
|
|
2044
2236
|
.replace(/\n\s*\n+/g, "\n").trim();
|
|
2045
2237
|
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")}`;
|
|
2046
2238
|
const data = { banner: "", roles: "", bannerDates: "", bannerDatesRaw: "", event: "", eventDates: "", eventDatesRaw: "" };
|
|
2047
|
-
//
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2239
|
+
// 当期卡池:**按统一规则**——当前时刻落在开放期间内的「棋盘」全部合并外显。
|
|
2240
|
+
// 卡片/悬停都走 banner + roles(与其它游戏一致):类型进 `banner`,
|
|
2241
|
+
// 悬停由面板兜底显示 `类型:角色` + 日期。**不构造 bannerHover**(同国服,见 §48.3d)。
|
|
2242
|
+
const now = nowMs();
|
|
2243
|
+
const nowYear = new Date(now).getFullYear();
|
|
2244
|
+
const active = parseNteBoards(text, nowYear)
|
|
2245
|
+
.filter((p) => p.endTs >= now && p.startTs <= now)
|
|
2246
|
+
.sort((a, b) => a.endTs - b.endTs);
|
|
2247
|
+
if (active.length > 0) {
|
|
2248
|
+
data.banner = [...new Set(active.map((p) => p.type))].join(" & ");
|
|
2249
|
+
data.roles = [...new Set(active.map((p) => p.roles).filter(Boolean))].join("、");
|
|
2250
|
+
// 窗口取结束最早的那个(与 selectCurrent 的 `first` 同口径)
|
|
2251
|
+
const first = active[0];
|
|
2252
|
+
data.bannerDates = `${fmt(new Date(first.startTs).getMonth() + 1, new Date(first.startTs).getDate(), new Date(first.startTs).getHours(), new Date(first.startTs).getMinutes())} ~ ${fmt(new Date(first.endTs).getMonth() + 1, new Date(first.endTs).getDate(), new Date(first.endTs).getHours(), new Date(first.endTs).getMinutes())}`;
|
|
2054
2253
|
data.bannerDatesRaw = data.bannerDates;
|
|
2055
2254
|
}
|
|
2056
|
-
// 当期活动:「X」限时活动 活动时间:M月D日(
|
|
2057
|
-
const ev = text.match(/「([^」]+)」限时活动[\s\S]{0,200}?活动时间:(\d+)月(\d+)日(
|
|
2255
|
+
// 当期活动:「X」限时活动 活动时间:M月D日(维护|版本)更新后|hh:mm-M月D日hh:mm
|
|
2256
|
+
const ev = text.match(/「([^」]+)」限时活动[\s\S]{0,200}?活动时间:(\d+)月(\d+)日(?:(?:维护|版本)更新后|(\d{1,2}):(\d{2}))\s*[-–—]\s*(\d+)月(\d+)日(\d{1,2}):(\d{2})/);
|
|
2058
2257
|
if (ev) {
|
|
2059
2258
|
const sMo = +ev[2], sD = +ev[3], sH = ev[4] ? +ev[4] : 11, sMi = ev[5] ? +ev[5] : 0;
|
|
2060
2259
|
const eMo = +ev[6], eD = +ev[7], eH = +ev[8], eMi = +ev[9];
|
|
@@ -3034,9 +3233,49 @@ export function createEngine(env) {
|
|
|
3034
3233
|
.replace(/“/g, "「").replace(/”/g, "」")
|
|
3035
3234
|
.replace(/…/g, "…").replace(/×/g, "×").replace(/•/g, "·")
|
|
3036
3235
|
.replace(/\n\s*\n+/g, "\n").trim();
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3236
|
+
return parseBaCnMaintText(text, nowYear);
|
|
3237
|
+
}
|
|
3238
|
+
// 国服公告里「招募」开头的东西**不是一类**。实测(09月24日维护更新说明):
|
|
3239
|
+
// 更新限时限定招募【秩序隐匿于粼粼波光中】 ← 限定角色卡池(当期主卡池)
|
|
3240
|
+
// 更新限时限定招募【思绪飘散在漫漫夏夜里】 ← 限定角色卡池(同期第二池)
|
|
3241
|
+
// 更新限时招募活动【100次免费招募】 ← 免费招募**活动**(不是卡池)
|
|
3242
|
+
// 更新限时招募活动【3★必得招募】 ← 必得**活动**
|
|
3243
|
+
// 更新限时招募活动【3★限定成员必得招募】 ← 限定必得**活动**
|
|
3244
|
+
// 更新限时招募活动【3★自选招募】 ← 自选**活动**
|
|
3245
|
+
// 关键区分点在**词序**:`…招募【` 后面紧跟 `】` 的是**卡池名**;
|
|
3246
|
+
// 而 `招募活动【` 是"招募"作定语去修饰"活动",是**活动**。
|
|
3247
|
+
// 曾经的糟糕修法是"把所有 招募 塞进一条正则按出现顺序取第一条" —— 那等于把
|
|
3248
|
+
// 限定卡池与招募活动混成同一类(用户指出)。这里按类型分级取,并显式排除活动类。
|
|
3249
|
+
const BA_CN_CHAR_POOL_RE = /^更新限时(限定复刻|限定|复刻|)招募$/;
|
|
3250
|
+
// 档位:限定角色池 > 普通角色池 > 限定复刻池 > 复刻池(数值越小越优先)
|
|
3251
|
+
const BA_CN_POOL_RANK = { 限定: 0, "": 1, 限定复刻: 2, 复刻: 3 };
|
|
3252
|
+
|
|
3253
|
+
function baCnPoolInfo(line) {
|
|
3254
|
+
const m = line.match(/更新限时([^【]*)【([^】]+)】/);
|
|
3255
|
+
if (!m) return null;
|
|
3256
|
+
const t = BA_CN_CHAR_POOL_RE.exec(`更新限时${m[1]}`);
|
|
3257
|
+
return { isPool: !!t, kind: t ? t[1] : "", name: m[2] };
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
// 国服维护公告正文 → 卡池/活动数据(纯函数:只吃**已清洗的文本**,不碰网络)。
|
|
3261
|
+
// 抽出来是为了能用合成文本精确回归 —— 这个源的坑全在措辞(见上面类型表),
|
|
3262
|
+
// 而措辞能不能认出来只能靠测解析,靠抓线上只能"等它坏了才发现"。
|
|
3263
|
+
// 无角色卡池时返回 null(调用方据此判"未公布")。
|
|
3264
|
+
function parseBaCnMaintText(text, nowYear) {
|
|
3265
|
+
// 卡池识别分两步:① 挑出**角色卡池**(排除招募活动);② 按档位选外显那一类。
|
|
3266
|
+
//
|
|
3267
|
+
// WHY 不能只认一种写法:措辞漂移过。旧写法 `更新限时招募【X】` 曾一度**一处都没有**
|
|
3268
|
+
// (实测 0 处),当前正文用的是 `更新限时限定招募【X】`。老代码只认旧写法 →
|
|
3269
|
+
// banner 为空 → 末尾 `if (!data.banner) return null` 把整条记录判死,
|
|
3270
|
+
// 表现为卡池与活动**两列同时**「无匹配/未公布」(两列共用本函数)。
|
|
3271
|
+
const bannerPools = [];
|
|
3272
|
+
for (const line of text.split("\n")) {
|
|
3273
|
+
const info = baCnPoolInfo(line);
|
|
3274
|
+
if (info && info.isPool) bannerPools.push({ ...info, line });
|
|
3275
|
+
}
|
|
3276
|
+
// 按(档位, 出现顺序)取最优:稳定排序即可,因为 bannerPools 已按文档顺序收集
|
|
3277
|
+
bannerPools.sort((a, b) => (BA_CN_POOL_RANK[a.kind] ?? 9) - (BA_CN_POOL_RANK[b.kind] ?? 9));
|
|
3278
|
+
const bannerPool = bannerPools[0] || null;
|
|
3040
3279
|
// 当期活动名(第一个"更新限时活动【X】")
|
|
3041
3280
|
const eventM = text.match(/更新限时活动【([^】]+)】/);
|
|
3042
3281
|
// 维护开始时间 "08月20日 14:00"
|
|
@@ -3053,37 +3292,56 @@ export function createEngine(env) {
|
|
|
3053
3292
|
const eEnd = new Date(nowYear, mo - 1, d + 28, 13, 59);
|
|
3054
3293
|
eventDates = `${start} ~ ${fmt(eEnd.getMonth() + 1, eEnd.getDate(), eEnd.getHours(), eEnd.getMinutes())}`;
|
|
3055
3294
|
}
|
|
3056
|
-
//
|
|
3057
|
-
// 池名 + 该行成员名,如
|
|
3295
|
+
// 公告里同期**全部角色卡池**(只含卡池,不含招募活动):池名 + 该行成员名,如
|
|
3058
3296
|
// 2、更新限时招募【夏日思绪长…】,…全新3★成员「桔梗(泳装)」、2★成员「莲华(泳装)」登场
|
|
3059
|
-
const recPools =
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
//
|
|
3065
|
-
|
|
3297
|
+
const recPools = bannerPools.map((p) => {
|
|
3298
|
+
const members = [...new Set([...p.line.matchAll(/(?:\d★)?(?:限定)?成员\s*[「“"]([^」”"]+)[」”"]/g)].map((x) => baRoleName(x[1].trim())).filter(Boolean))];
|
|
3299
|
+
return { name: p.name, kind: p.kind, members };
|
|
3300
|
+
});
|
|
3301
|
+
// ---- 外显 = **卡池类型**(对齐国际服「特選招募」的呈现方式),不是某个池名 ----
|
|
3302
|
+
//
|
|
3303
|
+
// WHY 改成显示类型(用户要求,2026-09-30):国服一期公告常同时开**同类多个池**
|
|
3304
|
+
// (当期两个「限定招募」),只显示第一个池名会漏掉另一个池;而卡池类型才是
|
|
3305
|
+
// "这一期在开什么"的稳定答案。国际服走结构化日程表,外显本来就是类别(`特選招募`),
|
|
3306
|
+
// 这里对齐它。
|
|
3307
|
+
// 同类型多池的**角色合并**展示(同国际服 roles 的做法);悬停只列同类型那些池,
|
|
3308
|
+
// 不把其它档位的池混进来(否则"显示限定招募、悬停却列着复刻池"自相矛盾)。
|
|
3309
|
+
//
|
|
3310
|
+
// 类型只放在 `banner` 里(悬停第一行会显示它,与国际服 `特選招募` 的位置一致)。
|
|
3311
|
+
// **不另外加 `bannerKind` 字段**:面板卡片格的规则是"有角色就显示角色",
|
|
3312
|
+
// 而 core 的 Result JSON 是冻结契约、字段白名单外的会被丢掉(实测过),
|
|
3313
|
+
// 想让卡片显示类型就得改契约。用户 2026-09-30 选择方案 A(类型只进悬停),
|
|
3314
|
+
// 故这里不留没有消费者的字段。
|
|
3315
|
+
const bannerKind = bannerPool ? bannerPool.kind : null;
|
|
3316
|
+
// 类型名 = **限时[限定][复刻]招募** —— 注意**不带 `更新`**。
|
|
3317
|
+
//
|
|
3318
|
+
// 公告原文是 `2、更新限时限定招募【秩序隐匿于粼粼波光中】`,断句是:
|
|
3319
|
+
// 「更新」= 公告的动词(这期更新了什么),「限时限定招募」= 卡池类型。
|
|
3320
|
+
// 我最初写成 `更新限时${kind}招募`(`更新限时限定招募`)是把动词也吞进了类型名——
|
|
3321
|
+
// 卡池不叫"更新限时限定招募",就叫"限时限定招募"(用户指出)。
|
|
3322
|
+
const bannerTypeName = bannerPool ? `限时${bannerKind}招募` : "";
|
|
3323
|
+
const sameKindPools = bannerPool ? recPools.filter((p) => p.kind === bannerKind) : [];
|
|
3324
|
+
const roleNames = [...new Set(sameKindPools.flatMap((p) => p.members))];
|
|
3066
3325
|
const data = {
|
|
3067
|
-
banner:
|
|
3326
|
+
banner: bannerTypeName,
|
|
3068
3327
|
roles: roleNames.join("、"),
|
|
3069
3328
|
bannerDates,
|
|
3070
3329
|
event: eventM ? eventM[1] : "",
|
|
3071
3330
|
eventDates
|
|
3072
3331
|
};
|
|
3073
|
-
//
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
}
|
|
3332
|
+
// 卡池列悬停:**不单独构造 `bannerHover`**(用户要求"悬停显示卡池类型",2026-09-30)。
|
|
3333
|
+
//
|
|
3334
|
+
// 原因:国服的"类型"已经在 `banner` 里(如 `更新限时限定招募`),而面板的悬停规则是
|
|
3335
|
+
// `title = withFailNote(g.bannerHover || gachaTitle, …)`
|
|
3336
|
+
// `gachaTitle = (g.roles ? `${g.banner}:${g.roles}` : g.banner) + 日期`
|
|
3337
|
+
// 所以**不设 bannerHover 时,悬停自然就是**
|
|
3338
|
+
// `更新限时限定招募:莲见(泳装)、圣娅(泳装)`
|
|
3339
|
+
// `09-24 14:00 ~ 10-08 13:59`
|
|
3340
|
+
// —— 已含类型,且类型与卡片口径一致。
|
|
3341
|
+
//
|
|
3342
|
+
// 曾试过"每池一行、以类型开头",但国服一期常开**同类多池**,那样会输出多行**完全一样**
|
|
3343
|
+
// 的文字(只有成员不同);而按类型归并成一条后,`buildPoolHover` 对 0/1 池返回空串
|
|
3344
|
+
// (全站共用的兜底约定)。结论:这里根本不需要 bannerHover,交给 UI 的兜底更准。
|
|
3087
3345
|
if (!data.banner) return null;
|
|
3088
3346
|
return data;
|
|
3089
3347
|
}
|
|
@@ -3542,9 +3800,13 @@ export function createEngine(env) {
|
|
|
3542
3800
|
d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "" } : null
|
|
3543
3801
|
)
|
|
3544
3802
|
},
|
|
3545
|
-
//
|
|
3803
|
+
// 鸣潮:**默认=同一份官方公告**(`recommend` 组里 tag=5 为限时活动),与卡池同址
|
|
3804
|
+
// → 一次请求复用两侧数据(refresh 的同址复用优化)。
|
|
3805
|
+
// ⚠️ 2026-10-01 改:Bwiki 活动日历**已停更**(最新一条结束于 2026/9/29),
|
|
3806
|
+
// 所以把官方提为默认、Bwiki 降级为备选(`wuwa-event-bwiki`),仍可在设置里手动切回。
|
|
3546
3807
|
wuwa: {
|
|
3547
|
-
default:
|
|
3808
|
+
default: (url, signal) => fetchWuwaEventsOfficial(url, signal),
|
|
3809
|
+
"wuwa-event-bwiki": mkMediaWiki((html) => {
|
|
3548
3810
|
const d = parseWuwaCalendar(html);
|
|
3549
3811
|
return d ? { event: d.banner, eventDates: d.bannerDates || "", eventDatesRaw: d.bannerDatesRaw || d.bannerDates || "", eventHover: d.eventHover || "" } : null;
|
|
3550
3812
|
})
|
|
@@ -3660,9 +3922,21 @@ export function createEngine(env) {
|
|
|
3660
3922
|
let evData = null;
|
|
3661
3923
|
let eventFail = null;
|
|
3662
3924
|
if (source.eventUrl) {
|
|
3663
|
-
//
|
|
3925
|
+
// 活动侧自己的抓取器(与卡池侧各自独立选择,来源可以完全不同)。
|
|
3926
|
+
// 注意:**默认路径就是"活动侧自己抓"** —— 解耦是常态,复用只是优化。
|
|
3664
3927
|
const evFetcher = eventFetcherFor(source, eventUrl);
|
|
3665
|
-
|
|
3928
|
+
// ── 同址复用:**命中才触发**的分支 ──────────────────────────────
|
|
3929
|
+
// 只有两个条件**同时**成立才复用,否则一律走下面的独立抓取:
|
|
3930
|
+
// ① 两侧地址相同(同一次请求本来就能拿到两侧数据)
|
|
3931
|
+
// ② 卡池那次的载荷**确实带了活动字段**(`event`)
|
|
3932
|
+
// 复用只是"省掉一次本来会重复的请求",**不是解耦的腿**:
|
|
3933
|
+
// 活动侧的抓取器、失败归因、来源选择都保持独立(见 §53)。
|
|
3934
|
+
//
|
|
3935
|
+
// 2026-10-01 实测(11 内置条目逐条):复用支命中 5 次
|
|
3936
|
+
// (wuwa / nte / r1999 / ba-cn / ba-global —— 它们的卡池载荷本身就含活动字段),
|
|
3937
|
+
// 其余 6 条目(zzz / genshin / hsr / arknights / ba-jp / endfield)走独立抓取。
|
|
3938
|
+
const reuseSameUrl = eventUrl === gachaUrl && !!(g.data && g.data.event);
|
|
3939
|
+
if (reuseSameUrl) {
|
|
3666
3940
|
// 同 URL:活动字段就在卡池载荷里,不重复抓
|
|
3667
3941
|
evData = {
|
|
3668
3942
|
event: g.data.event,
|
|
@@ -3671,12 +3945,20 @@ export function createEngine(env) {
|
|
|
3671
3945
|
eventHover: g.data.eventHover || ""
|
|
3672
3946
|
};
|
|
3673
3947
|
} else if (eventUrl === gachaUrl && !evFetcher) {
|
|
3674
|
-
//
|
|
3675
|
-
//
|
|
3676
|
-
//
|
|
3677
|
-
//
|
|
3948
|
+
// ── 同址但活动侧**没有自己的抓取器**(该条目就只有这一份载荷可用)──
|
|
3949
|
+
// ⚠️ **这一支不是死代码,别删**:**自定义条目**可以没有注册抓取器 ——
|
|
3950
|
+
// `getAllEntries` 里 customEntries 带 `custom:true` + 用户填的 url/eventUrl,
|
|
3951
|
+
// 而 GACHA_FETCHERS / EVENT_FETCHERS 里没有它们的 id → `eventFetcherFor` 返回 null。
|
|
3952
|
+
// 语义:
|
|
3953
|
+
// · 卡池那次成功但载荷没有活动字段 → 该侧就是"未公布"(nomatch),
|
|
3954
|
+
// 且**不发**那次注定被 CORS 拦的直连;
|
|
3955
|
+
// · 卡池那次本身失败 → 沿用**同一次请求**的失败原因(如 HTTP 567),
|
|
3956
|
+
// 而不是笼统报"无可用来源"。
|
|
3957
|
+
// 教训:我一度只按"内置 11 条目命中 0 次"就判它死代码 —— **错的**,
|
|
3958
|
+
// 把"当前没命中"当成了"不可达"。`_batch1` A3 用合成条目(sim-nte)刻意覆盖这两条语义。
|
|
3678
3959
|
eventFail = g.data ? { kind: "nomatch" } : (g.fail || { kind: "nomatch" });
|
|
3679
3960
|
} else {
|
|
3961
|
+
// 活动侧独立抓取(含"地址不同"与"同址但载荷无活动字段"两种情形)
|
|
3680
3962
|
const ev = await resolveSide("event", {
|
|
3681
3963
|
fetcher: evFetcher,
|
|
3682
3964
|
url: eventUrl,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gacha-calendar-core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.8",
|
|
4
4
|
"description": "二游活动/卡池排期抓取核心:11 款游戏的来源注册表 + 解析器 + 刷新编排 + 缓存降级。零依赖、零宿主环境依赖,任何 JS 运行时(浏览器扩展 background / Tauri / JavaScriptCore / ArkTS 嵌入式引擎)都能跑。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./core.mjs",
|