gacha-calendar-core 0.10.6 → 0.10.7
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 +220 -55
- 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.7";
|
|
8
8
|
//#endregion
|
|
9
9
|
|
|
10
10
|
//#region config
|
|
@@ -1165,17 +1165,36 @@ export function createEngine(env) {
|
|
|
1165
1165
|
|
|
1166
1166
|
// 选当期:优先"起始明确且覆盖 now"的主池;
|
|
1167
1167
|
// 其次"起始未知(版本更新后)但结束在未来"的主池(星铁/zzz 上半,此时起始已被 fillMissingStarts 补齐);
|
|
1168
|
-
//
|
|
1168
|
+
// **当期多个主池的角色全部合并**(见下方合并说明)。返回 null 时调用方回退内置数据。
|
|
1169
1169
|
// bannerDates 为补全后用于倒计时的文本;bannerDatesRaw 为源站原文(悬停展示)。
|
|
1170
1170
|
function selectCurrent(items, now) {
|
|
1171
|
+
// 先记下**补全前**哪些行的起点是"版本更新后"(startTs 原本为 null)。
|
|
1172
|
+
// 为什么要记:fillMissingStarts 会用"上一组结束时间"去猜这类行的起点,
|
|
1173
|
+
// 而那个猜测**并非总是对的** —— 星铁 4.6 上线时间期有两池:
|
|
1174
|
+
// 「韶艾裁英」4.6版本更新后 ~ 10-21 11:59
|
|
1175
|
+
// 「沧海萃珠」4.6版本更新后 ~ 11-10 15:00
|
|
1176
|
+
// 两者同为"4.6 上线",只是结束不同;按"上一组结束"补会把后者补成 10-21,
|
|
1177
|
+
// 于是"起点已过"判定失败、它被踢出当期 —— 而它其实覆盖当前时刻
|
|
1178
|
+
// (用户实测:卡片只显示绯英,真珠不见了)。
|
|
1179
|
+
const hadOpenStart = new Set(items.filter((it) => it.startTs == null).map((it) => it));
|
|
1171
1180
|
fillMissingStarts(items);
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1181
|
+
const inWindow = (it) => it.endTs != null && it.endTs >= now
|
|
1182
|
+
&& (it.startTs == null ? true : it.startTs <= now || hadOpenStart.has(it));
|
|
1183
|
+
// 说明最后一个条件:起点原本为 null(写作"版本更新后")的行,**一定已经开始**——
|
|
1184
|
+
// 源站表只列**已发布版本**的排期,"X.Y版本更新后"里的 X.Y 必已上线,
|
|
1185
|
+
// 所以它的真实起点在版本更新那天(≤ now),补出来的那个值不可信、不应用来判"未开始"。
|
|
1186
|
+
// 注意这里**不覆盖**补全后的 startTs:显示(bannerDates 等)仍用补出来的窗口,
|
|
1187
|
+
// 悬停也仍显示源站原文"4.6版本更新后 ~ …",不编造日期。
|
|
1188
|
+
const pool = items.filter(inWindow).filter((it) => it.isMain);
|
|
1175
1189
|
if (pool.length === 0) return null;
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1190
|
+
// 外显取哪个池的"名字与时间":结束最早的(越快结束越该被盯住)。
|
|
1191
|
+
//
|
|
1192
|
+
// ⚠️ 合并条件(用户要求,2026-09-30 改):
|
|
1193
|
+
// 旧 = 起止**完全相同**才合并 → 星铁同期两池结束不同就只合到自己,
|
|
1194
|
+
// 卡片只显示一个池的角色。
|
|
1195
|
+
// 新 = **当前时刻落在其持续区间内的主池全部合并**(就是这个 `pool`)。
|
|
1196
|
+
const first = pool.slice().sort((a, b) => a.endTs - b.endTs)[0];
|
|
1197
|
+
const roles = [...new Set(pool.map((it) => cleanRoles(it.roles)).filter(Boolean))].join("、");
|
|
1179
1198
|
return {
|
|
1180
1199
|
banner: first.banner,
|
|
1181
1200
|
roles,
|
|
@@ -1794,7 +1813,15 @@ export function createEngine(env) {
|
|
|
1794
1813
|
const d = new Date(iso);
|
|
1795
1814
|
return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1796
1815
|
};
|
|
1797
|
-
//
|
|
1816
|
+
// **统一规则(§49)**:当前时刻落在持续期间内的卡池**全部合并**外显。
|
|
1817
|
+
// 旧实现是"两个分支各自命中第一个就 return" → 只显示一个池,同期的另一个池看不到
|
|
1818
|
+
// (用户实测:终末地外显没合并)。现在先把两类候选都收集起来再合并。
|
|
1819
|
+
//
|
|
1820
|
+
// ⚠️ 这条路径**不走 `selectCurrent`**(canmoe 的 chunk 是压缩 JS,得单独解析),
|
|
1821
|
+
// 所以 §49 改 `selectCurrent` 时漏了这里 —— 教训:改"通用规则"要**枚举所有外显路径**,
|
|
1822
|
+
// 不能只改名字最像的那个函数。
|
|
1823
|
+
const active = [];
|
|
1824
|
+
// 1) 当期 d:windows 覆盖当前 → 收进来
|
|
1798
1825
|
const curM = js.match(/\bd=\{(.+?)\},\s*u=\[/);
|
|
1799
1826
|
if (curM) {
|
|
1800
1827
|
sawStructure = true;
|
|
@@ -1803,10 +1830,12 @@ export function createEngine(env) {
|
|
|
1803
1830
|
const roles = featuredM ? featuredM[1].trim() : "";
|
|
1804
1831
|
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
1832
|
const a = new Date(w[1]).getTime(), b = new Date(w[2]).getTime();
|
|
1806
|
-
if (a <= now && now <= b)
|
|
1833
|
+
if (a <= now && now <= b) {
|
|
1834
|
+
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])}` });
|
|
1835
|
+
}
|
|
1807
1836
|
}
|
|
1808
1837
|
}
|
|
1809
|
-
// 2) p 数组中的"当前进行中"
|
|
1838
|
+
// 2) p 数组中的"当前进行中"条目(过期当期后以此为兜底)—— 同样**全部收**,不是取第一个
|
|
1810
1839
|
const arr = extractCanmoePeriods(js);
|
|
1811
1840
|
if (arr) {
|
|
1812
1841
|
sawStructure = true;
|
|
@@ -1815,15 +1844,28 @@ export function createEngine(env) {
|
|
|
1815
1844
|
const pe = (e.match(/periodEnd\s*:\s*"([^"]*)"/) || [])[1];
|
|
1816
1845
|
if (!ps || !pe) continue;
|
|
1817
1846
|
const a = new Date(ps).getTime(), b = new Date(pe).getTime();
|
|
1818
|
-
if (a <= now && now <= b)
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1847
|
+
if (!(a <= now && now <= b)) continue;
|
|
1848
|
+
const title = (e.match(/title\s*:\s*"([^"]*)"/) || [])[1] || "";
|
|
1849
|
+
const subtitle = (e.match(/subtitle\s*:\s*"([^"]*)"/) || [])[1] || "";
|
|
1850
|
+
const version = (e.match(/version\s*:\s*"([^"]*)"/) || [])[1] || "";
|
|
1851
|
+
active.push({
|
|
1852
|
+
banner: title || `\u3010${version}\u3011${subtitle}`,
|
|
1853
|
+
roles: subtitle || title,
|
|
1854
|
+
startTs: a, endTs: b,
|
|
1855
|
+
dates: `${fmt(ps)} ~ ${fmt(pe)}`, raw: `${fmt(ps)} ~ ${fmt(pe)}`
|
|
1856
|
+
});
|
|
1825
1857
|
}
|
|
1826
1858
|
}
|
|
1859
|
+
if (active.length > 0) {
|
|
1860
|
+
// 外显名与窗口取**结束最早**的那个池(越快结束越该被盯住;与 selectCurrent 的 first 同口径)
|
|
1861
|
+
const first = active.slice().sort((x, y) => x.endTs - y.endTs)[0];
|
|
1862
|
+
return {
|
|
1863
|
+
banner: first.banner,
|
|
1864
|
+
roles: [...new Set(active.map((p) => p.roles).filter(Boolean))].join("、"),
|
|
1865
|
+
bannerDates: first.dates,
|
|
1866
|
+
bannerDatesRaw: first.raw
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1827
1869
|
// 结构在但没覆盖当前时刻 → null(未公布);连结构都没有 → undefined(页面改版,交上层决定)
|
|
1828
1870
|
return sawStructure ? null : void 0;
|
|
1829
1871
|
}
|
|
@@ -2034,7 +2076,64 @@ export function createEngine(env) {
|
|
|
2034
2076
|
return null;
|
|
2035
2077
|
}
|
|
2036
2078
|
|
|
2037
|
-
//
|
|
2079
|
+
// 异环公告里的「棋盘」条目解析(角色卡池)。
|
|
2080
|
+
//
|
|
2081
|
+
// 为什么从**「棋盘」**入手(用户建议,2026-09-30):
|
|
2082
|
+
// 异环的角色卡池在公告里叫「X」**限定棋盘**,条目形如
|
|
2083
|
+
// ● 全新限定S级角色「黑羽」
|
|
2084
|
+
// 开放时间:9月24日版本更新后-10月15日05:59
|
|
2085
|
+
// 棋盘说明:可通过「预言终幕时」限定棋盘获得S级角色「黑羽」。…
|
|
2086
|
+
// `棋盘说明` 是**角色卡池独有的锚点** —— 弧盘走 `研募说明`、剧情段没有这个字段。
|
|
2087
|
+
// 用「有棋盘说明」筛,比用"全新限定S级角色"精确:后者漏掉**返场**(`限定S级角色「安魂曲」返场`,
|
|
2088
|
+
// 没有"全新"二字),而那也是一张在开的角色池。
|
|
2089
|
+
//
|
|
2090
|
+
// 旧实现只认 `全新限定S级角色「X」…开放时间:N月N日**维护**更新后-…` 一条正则,
|
|
2091
|
+
// 而现公告写的是 `**版本**更新后` → 一条都匹配不上 → 卡池为空 → `if (!data.banner) return null`
|
|
2092
|
+
// → `fetchNteWanmei` 继续往下试,最终拿 index1 页那篇**已过期**的旧公告冒充当期。
|
|
2093
|
+
function parseNteBoards(text, nowYear) {
|
|
2094
|
+
const lines = text.split("\n").map((l) => l.trim());
|
|
2095
|
+
// 「一、 全新角色&弧盘」这一段的边界(只在这里找,避免匹配到别处的"开放时间")
|
|
2096
|
+
const start = lines.findIndex((l) => /^一、/.test(l));
|
|
2097
|
+
if (start < 0) return [];
|
|
2098
|
+
let end = lines.findIndex((l, i) => i > start && /^二、/.test(l));
|
|
2099
|
+
if (end < 0) end = lines.length;
|
|
2100
|
+
const pools = [];
|
|
2101
|
+
for (let i = start; i < end; i++) {
|
|
2102
|
+
const m = lines[i].match(/^●\s*(?:全新)?(限定S级角色|S级角色)「([^」]+)」(返场)?/);
|
|
2103
|
+
if (!m) continue;
|
|
2104
|
+
// 往后找该条目的「开放时间」与「棋盘说明」(各限 8 行内)
|
|
2105
|
+
let range = null, board = "";
|
|
2106
|
+
for (let j = i + 1; j < Math.min(end, i + 8); j++) {
|
|
2107
|
+
if (!range) {
|
|
2108
|
+
const t = lines[j].match(/^开放时间:(\d+)月(\d+)日(?:(?:维护|版本)更新后|(\d{1,2}):(\d{2}))\s*[-–—]\s*(\d+)月(\d+)日(\d{1,2}):(\d{2})/);
|
|
2109
|
+
if (t) {
|
|
2110
|
+
range = {
|
|
2111
|
+
sMo: +t[1], sD: +t[2], sH: t[3] ? +t[3] : 11, sMi: t[4] ? +t[4] : 0,
|
|
2112
|
+
eMo: +t[5], eD: +t[6], eH: +t[7], eMi: +t[8]
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
if (!board) {
|
|
2117
|
+
const b = lines[j].match(/棋盘说明:可通过「([^」]+)」限定棋盘获得/);
|
|
2118
|
+
if (b) board = b[1];
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
// **有棋盘说明才是角色卡池**(弧盘那条走研募说明,会在这里被排除)
|
|
2122
|
+
if (!range || !board) continue;
|
|
2123
|
+
pools.push({
|
|
2124
|
+
name: board,
|
|
2125
|
+
// 类型:`全新限定S级角色` → 限定棋盘;`限定S级角色…返场` → 返场限定棋盘。
|
|
2126
|
+
// 注意 `限定` 属于**类型的一部分**,不是动词/修饰(与国服"更新限时限定招募"同理)。
|
|
2127
|
+
type: `${m[3] ? "返场" : ""}限定棋盘`,
|
|
2128
|
+
roles: baRoleName(m[2]),
|
|
2129
|
+
startTs: new Date(nowYear, range.sMo - 1, range.sD, range.sH, range.sMi).getTime(),
|
|
2130
|
+
endTs: new Date(nowYear, range.eMo - 1, range.eD, range.eH, range.eMi).getTime()
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
return pools;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// 解析官网公告正文 → 当期卡池(有「棋盘说明」的角色卡池,按**统一规则**合并)+ 当期活动(限时活动)
|
|
2038
2137
|
function parseNteWanmei(html) {
|
|
2039
2138
|
const text = String(html || "")
|
|
2040
2139
|
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
@@ -2044,17 +2143,24 @@ export function createEngine(env) {
|
|
|
2044
2143
|
.replace(/\n\s*\n+/g, "\n").trim();
|
|
2045
2144
|
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
2145
|
const data = { banner: "", roles: "", bannerDates: "", bannerDatesRaw: "", event: "", eventDates: "", eventDatesRaw: "" };
|
|
2047
|
-
//
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2146
|
+
// 当期卡池:**按统一规则**——当前时刻落在开放期间内的「棋盘」全部合并外显。
|
|
2147
|
+
// 卡片/悬停都走 banner + roles(与其它游戏一致):类型进 `banner`,
|
|
2148
|
+
// 悬停由面板兜底显示 `类型:角色` + 日期。**不构造 bannerHover**(同国服,见 §48.3d)。
|
|
2149
|
+
const now = nowMs();
|
|
2150
|
+
const nowYear = new Date(now).getFullYear();
|
|
2151
|
+
const active = parseNteBoards(text, nowYear)
|
|
2152
|
+
.filter((p) => p.endTs >= now && p.startTs <= now)
|
|
2153
|
+
.sort((a, b) => a.endTs - b.endTs);
|
|
2154
|
+
if (active.length > 0) {
|
|
2155
|
+
data.banner = [...new Set(active.map((p) => p.type))].join(" & ");
|
|
2156
|
+
data.roles = [...new Set(active.map((p) => p.roles).filter(Boolean))].join("、");
|
|
2157
|
+
// 窗口取结束最早的那个(与 selectCurrent 的 `first` 同口径)
|
|
2158
|
+
const first = active[0];
|
|
2159
|
+
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
2160
|
data.bannerDatesRaw = data.bannerDates;
|
|
2055
2161
|
}
|
|
2056
|
-
// 当期活动:「X」限时活动 活动时间:M月D日(
|
|
2057
|
-
const ev = text.match(/「([^」]+)」限时活动[\s\S]{0,200}?活动时间:(\d+)月(\d+)日(
|
|
2162
|
+
// 当期活动:「X」限时活动 活动时间:M月D日(维护|版本)更新后|hh:mm-M月D日hh:mm
|
|
2163
|
+
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
2164
|
if (ev) {
|
|
2059
2165
|
const sMo = +ev[2], sD = +ev[3], sH = ev[4] ? +ev[4] : 11, sMi = ev[5] ? +ev[5] : 0;
|
|
2060
2166
|
const eMo = +ev[6], eD = +ev[7], eH = +ev[8], eMi = +ev[9];
|
|
@@ -3034,9 +3140,49 @@ export function createEngine(env) {
|
|
|
3034
3140
|
.replace(/“/g, "「").replace(/”/g, "」")
|
|
3035
3141
|
.replace(/…/g, "…").replace(/×/g, "×").replace(/•/g, "·")
|
|
3036
3142
|
.replace(/\n\s*\n+/g, "\n").trim();
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3143
|
+
return parseBaCnMaintText(text, nowYear);
|
|
3144
|
+
}
|
|
3145
|
+
// 国服公告里「招募」开头的东西**不是一类**。实测(09月24日维护更新说明):
|
|
3146
|
+
// 更新限时限定招募【秩序隐匿于粼粼波光中】 ← 限定角色卡池(当期主卡池)
|
|
3147
|
+
// 更新限时限定招募【思绪飘散在漫漫夏夜里】 ← 限定角色卡池(同期第二池)
|
|
3148
|
+
// 更新限时招募活动【100次免费招募】 ← 免费招募**活动**(不是卡池)
|
|
3149
|
+
// 更新限时招募活动【3★必得招募】 ← 必得**活动**
|
|
3150
|
+
// 更新限时招募活动【3★限定成员必得招募】 ← 限定必得**活动**
|
|
3151
|
+
// 更新限时招募活动【3★自选招募】 ← 自选**活动**
|
|
3152
|
+
// 关键区分点在**词序**:`…招募【` 后面紧跟 `】` 的是**卡池名**;
|
|
3153
|
+
// 而 `招募活动【` 是"招募"作定语去修饰"活动",是**活动**。
|
|
3154
|
+
// 曾经的糟糕修法是"把所有 招募 塞进一条正则按出现顺序取第一条" —— 那等于把
|
|
3155
|
+
// 限定卡池与招募活动混成同一类(用户指出)。这里按类型分级取,并显式排除活动类。
|
|
3156
|
+
const BA_CN_CHAR_POOL_RE = /^更新限时(限定复刻|限定|复刻|)招募$/;
|
|
3157
|
+
// 档位:限定角色池 > 普通角色池 > 限定复刻池 > 复刻池(数值越小越优先)
|
|
3158
|
+
const BA_CN_POOL_RANK = { 限定: 0, "": 1, 限定复刻: 2, 复刻: 3 };
|
|
3159
|
+
|
|
3160
|
+
function baCnPoolInfo(line) {
|
|
3161
|
+
const m = line.match(/更新限时([^【]*)【([^】]+)】/);
|
|
3162
|
+
if (!m) return null;
|
|
3163
|
+
const t = BA_CN_CHAR_POOL_RE.exec(`更新限时${m[1]}`);
|
|
3164
|
+
return { isPool: !!t, kind: t ? t[1] : "", name: m[2] };
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
// 国服维护公告正文 → 卡池/活动数据(纯函数:只吃**已清洗的文本**,不碰网络)。
|
|
3168
|
+
// 抽出来是为了能用合成文本精确回归 —— 这个源的坑全在措辞(见上面类型表),
|
|
3169
|
+
// 而措辞能不能认出来只能靠测解析,靠抓线上只能"等它坏了才发现"。
|
|
3170
|
+
// 无角色卡池时返回 null(调用方据此判"未公布")。
|
|
3171
|
+
function parseBaCnMaintText(text, nowYear) {
|
|
3172
|
+
// 卡池识别分两步:① 挑出**角色卡池**(排除招募活动);② 按档位选外显那一类。
|
|
3173
|
+
//
|
|
3174
|
+
// WHY 不能只认一种写法:措辞漂移过。旧写法 `更新限时招募【X】` 曾一度**一处都没有**
|
|
3175
|
+
// (实测 0 处),当前正文用的是 `更新限时限定招募【X】`。老代码只认旧写法 →
|
|
3176
|
+
// banner 为空 → 末尾 `if (!data.banner) return null` 把整条记录判死,
|
|
3177
|
+
// 表现为卡池与活动**两列同时**「无匹配/未公布」(两列共用本函数)。
|
|
3178
|
+
const bannerPools = [];
|
|
3179
|
+
for (const line of text.split("\n")) {
|
|
3180
|
+
const info = baCnPoolInfo(line);
|
|
3181
|
+
if (info && info.isPool) bannerPools.push({ ...info, line });
|
|
3182
|
+
}
|
|
3183
|
+
// 按(档位, 出现顺序)取最优:稳定排序即可,因为 bannerPools 已按文档顺序收集
|
|
3184
|
+
bannerPools.sort((a, b) => (BA_CN_POOL_RANK[a.kind] ?? 9) - (BA_CN_POOL_RANK[b.kind] ?? 9));
|
|
3185
|
+
const bannerPool = bannerPools[0] || null;
|
|
3040
3186
|
// 当期活动名(第一个"更新限时活动【X】")
|
|
3041
3187
|
const eventM = text.match(/更新限时活动【([^】]+)】/);
|
|
3042
3188
|
// 维护开始时间 "08月20日 14:00"
|
|
@@ -3053,37 +3199,56 @@ export function createEngine(env) {
|
|
|
3053
3199
|
const eEnd = new Date(nowYear, mo - 1, d + 28, 13, 59);
|
|
3054
3200
|
eventDates = `${start} ~ ${fmt(eEnd.getMonth() + 1, eEnd.getDate(), eEnd.getHours(), eEnd.getMinutes())}`;
|
|
3055
3201
|
}
|
|
3056
|
-
//
|
|
3057
|
-
// 池名 + 该行成员名,如
|
|
3202
|
+
// 公告里同期**全部角色卡池**(只含卡池,不含招募活动):池名 + 该行成员名,如
|
|
3058
3203
|
// 2、更新限时招募【夏日思绪长…】,…全新3★成员「桔梗(泳装)」、2★成员「莲华(泳装)」登场
|
|
3059
|
-
const recPools =
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
//
|
|
3065
|
-
|
|
3204
|
+
const recPools = bannerPools.map((p) => {
|
|
3205
|
+
const members = [...new Set([...p.line.matchAll(/(?:\d★)?(?:限定)?成员\s*[「“"]([^」”"]+)[」”"]/g)].map((x) => baRoleName(x[1].trim())).filter(Boolean))];
|
|
3206
|
+
return { name: p.name, kind: p.kind, members };
|
|
3207
|
+
});
|
|
3208
|
+
// ---- 外显 = **卡池类型**(对齐国际服「特選招募」的呈现方式),不是某个池名 ----
|
|
3209
|
+
//
|
|
3210
|
+
// WHY 改成显示类型(用户要求,2026-09-30):国服一期公告常同时开**同类多个池**
|
|
3211
|
+
// (当期两个「限定招募」),只显示第一个池名会漏掉另一个池;而卡池类型才是
|
|
3212
|
+
// "这一期在开什么"的稳定答案。国际服走结构化日程表,外显本来就是类别(`特選招募`),
|
|
3213
|
+
// 这里对齐它。
|
|
3214
|
+
// 同类型多池的**角色合并**展示(同国际服 roles 的做法);悬停只列同类型那些池,
|
|
3215
|
+
// 不把其它档位的池混进来(否则"显示限定招募、悬停却列着复刻池"自相矛盾)。
|
|
3216
|
+
//
|
|
3217
|
+
// 类型只放在 `banner` 里(悬停第一行会显示它,与国际服 `特選招募` 的位置一致)。
|
|
3218
|
+
// **不另外加 `bannerKind` 字段**:面板卡片格的规则是"有角色就显示角色",
|
|
3219
|
+
// 而 core 的 Result JSON 是冻结契约、字段白名单外的会被丢掉(实测过),
|
|
3220
|
+
// 想让卡片显示类型就得改契约。用户 2026-09-30 选择方案 A(类型只进悬停),
|
|
3221
|
+
// 故这里不留没有消费者的字段。
|
|
3222
|
+
const bannerKind = bannerPool ? bannerPool.kind : null;
|
|
3223
|
+
// 类型名 = **限时[限定][复刻]招募** —— 注意**不带 `更新`**。
|
|
3224
|
+
//
|
|
3225
|
+
// 公告原文是 `2、更新限时限定招募【秩序隐匿于粼粼波光中】`,断句是:
|
|
3226
|
+
// 「更新」= 公告的动词(这期更新了什么),「限时限定招募」= 卡池类型。
|
|
3227
|
+
// 我最初写成 `更新限时${kind}招募`(`更新限时限定招募`)是把动词也吞进了类型名——
|
|
3228
|
+
// 卡池不叫"更新限时限定招募",就叫"限时限定招募"(用户指出)。
|
|
3229
|
+
const bannerTypeName = bannerPool ? `限时${bannerKind}招募` : "";
|
|
3230
|
+
const sameKindPools = bannerPool ? recPools.filter((p) => p.kind === bannerKind) : [];
|
|
3231
|
+
const roleNames = [...new Set(sameKindPools.flatMap((p) => p.members))];
|
|
3066
3232
|
const data = {
|
|
3067
|
-
banner:
|
|
3233
|
+
banner: bannerTypeName,
|
|
3068
3234
|
roles: roleNames.join("、"),
|
|
3069
3235
|
bannerDates,
|
|
3070
3236
|
event: eventM ? eventM[1] : "",
|
|
3071
3237
|
eventDates
|
|
3072
3238
|
};
|
|
3073
|
-
//
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
}
|
|
3239
|
+
// 卡池列悬停:**不单独构造 `bannerHover`**(用户要求"悬停显示卡池类型",2026-09-30)。
|
|
3240
|
+
//
|
|
3241
|
+
// 原因:国服的"类型"已经在 `banner` 里(如 `更新限时限定招募`),而面板的悬停规则是
|
|
3242
|
+
// `title = withFailNote(g.bannerHover || gachaTitle, …)`
|
|
3243
|
+
// `gachaTitle = (g.roles ? `${g.banner}:${g.roles}` : g.banner) + 日期`
|
|
3244
|
+
// 所以**不设 bannerHover 时,悬停自然就是**
|
|
3245
|
+
// `更新限时限定招募:莲见(泳装)、圣娅(泳装)`
|
|
3246
|
+
// `09-24 14:00 ~ 10-08 13:59`
|
|
3247
|
+
// —— 已含类型,且类型与卡片口径一致。
|
|
3248
|
+
//
|
|
3249
|
+
// 曾试过"每池一行、以类型开头",但国服一期常开**同类多池**,那样会输出多行**完全一样**
|
|
3250
|
+
// 的文字(只有成员不同);而按类型归并成一条后,`buildPoolHover` 对 0/1 池返回空串
|
|
3251
|
+
// (全站共用的兜底约定)。结论:这里根本不需要 bannerHover,交给 UI 的兜底更准。
|
|
3087
3252
|
if (!data.banner) return null;
|
|
3088
3253
|
return data;
|
|
3089
3254
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gacha-calendar-core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.7",
|
|
4
4
|
"description": "二游活动/卡池排期抓取核心:11 款游戏的来源注册表 + 解析器 + 刷新编排 + 缓存降级。零依赖、零宿主环境依赖,任何 JS 运行时(浏览器扩展 background / Tauri / JavaScriptCore / ArkTS 嵌入式引擎)都能跑。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./core.mjs",
|