gacha-calendar-core 0.9.8 → 0.9.12
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 +168 -29
- package/package.json +1 -1
package/core.mjs
CHANGED
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
id: "zzz",
|
|
83
|
-
parserVersion:
|
|
83
|
+
parserVersion: 2,
|
|
84
84
|
name: "绝区零",
|
|
85
85
|
icon: "https://storage.moegirl.org.cn/moegirl/commons/3/3e/ZZZ_miYoYo_logo.jpg!/fw/64",
|
|
86
86
|
source: "官方公告",
|
|
@@ -91,9 +91,18 @@
|
|
|
91
91
|
altSources: [
|
|
92
92
|
{ label: "Bwiki 往期调频", url: ZZZ_BWIKI_URL, fetcher: "zzz-bwiki" }
|
|
93
93
|
],
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
// 独立活动源:官方公告(api-takumi-static,与卡池侧同一接口,经 host 代理)。
|
|
95
|
+
// 「…活动说明」公告正文自带【活动时间】起止(含"X.Y版本更新后/版本结束"折算),
|
|
96
|
+
// 官方口径最及时;Bwiki 活动一览作为备选(编辑滞后时反而无当期内容)。
|
|
97
|
+
eventUrl: ZZZ_NEWS_LIST_URL,
|
|
98
|
+
eventSource: "官方公告",
|
|
99
|
+
eventAltSources: [
|
|
100
|
+
{
|
|
101
|
+
label: "Bwiki 活动一览",
|
|
102
|
+
url: "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",
|
|
103
|
+
fetcher: "zzz-event-bwiki"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
97
106
|
},
|
|
98
107
|
{
|
|
99
108
|
id: "wuwa",
|
|
@@ -129,7 +138,7 @@
|
|
|
129
138
|
},
|
|
130
139
|
{
|
|
131
140
|
id: "endfield",
|
|
132
|
-
parserVersion:
|
|
141
|
+
parserVersion: 2,
|
|
133
142
|
name: "明日方舟:终末地",
|
|
134
143
|
icon: "https://storage.moegirl.org.cn/moegirl/commons/f/f1/ArknightsEndfieldAppIcon.png!/fw/64",
|
|
135
144
|
source: "Canmoe",
|
|
@@ -224,7 +233,7 @@
|
|
|
224
233
|
},
|
|
225
234
|
{
|
|
226
235
|
id: "nte",
|
|
227
|
-
parserVersion:
|
|
236
|
+
parserVersion: 2,
|
|
228
237
|
name: "异环",
|
|
229
238
|
icon: "https://storage.moegirl.org.cn/moegirl/commons/8/8c/YH_APP.png!/fw/64",
|
|
230
239
|
source: "官网公告",
|
|
@@ -1349,6 +1358,93 @@ export function createEngine(env) {
|
|
|
1349
1358
|
return selectCurrent(parseAllBwiki(html), now);
|
|
1350
1359
|
}
|
|
1351
1360
|
|
|
1361
|
+
// 绝区零:官方公告列表(api-takumi-static,与卡池侧同一接口,经 host 代理)→ 当期活动。
|
|
1362
|
+
// 活动时间就写在公告正文里(【活动时间】A ~ B),**不需要再抓详情页**;
|
|
1363
|
+
// A/B 可能是绝对时间,也可能是"X.Y版本更新后" / "X.Y版本结束"——用「X.Y版本更新公告」的发布时间折算:
|
|
1364
|
+
// 版本起点 = 该版本更新公告发布时间;版本结束 = 下一个已知版本起点 − 1 分钟。
|
|
1365
|
+
// 当前版本还没有下一版本公告 → 结束时间未知:这类活动**保留**(endTs=null),
|
|
1366
|
+
// 由 sortEventItems / pickEventPrimary 的既有规则自然沉到外显与悬停的最后,不跳过、不丢弃。
|
|
1367
|
+
const ZZZ_EVENT_WINDOW_RE = /((?:\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}\s+\d{1,2}:\d{2})|(?:\d+\.\d+\s*版本更新后))\s*[~~\-—]\s*((?:\d{4}[\/\-]\d{1,2}[\/\-]\d{1,2}\s+\d{1,2}:\d{2})|(?:\d+\.\d+\s*版本结束))/g;
|
|
1368
|
+
|
|
1369
|
+
function parseZzzEventsOfficial(payload, now = nowMs()) {
|
|
1370
|
+
const list = Array.isArray(payload?.data?.list) ? payload.data.list : [];
|
|
1371
|
+
const clean = (s) => stripTags(s);
|
|
1372
|
+
// 版本起点表:取该版本的「更新公告」发布时间。
|
|
1373
|
+
// 必须排除「X.Y版本…预下载开启&更新通知」——它比正式更新早 1~2 天发布,
|
|
1374
|
+
// 若当成版本起点,会把上一版本"版本结束"型活动提前判死、并让新版本活动提前出现。
|
|
1375
|
+
const verStart = {};
|
|
1376
|
+
for (const it of list) {
|
|
1377
|
+
const title = clean(it?.sTitle);
|
|
1378
|
+
const vm = title.match(/(\d+\.\d+)\s*版本/);
|
|
1379
|
+
if (!vm || !/更新(?:公告|通知)/.test(title)) continue;
|
|
1380
|
+
if (/预下载|预约|前瞻|预抽/.test(title)) continue;
|
|
1381
|
+
const ts = parseTime(it.dtStartTime).ts;
|
|
1382
|
+
if (ts != null && verStart[vm[1]] == null) verStart[vm[1]] = ts;
|
|
1383
|
+
}
|
|
1384
|
+
const versions = Object.keys(verStart).sort((a, b) => verStart[a] - verStart[b]);
|
|
1385
|
+
// 折算一个窗口的两个端点;返回 null 表示**窗口此刻不成立**(含"该版本还没开始/无法判定")。
|
|
1386
|
+
// 规则:绝对时间直接用;"X.Y版本更新后"要求该版本已开始(版本更新公告已发布);
|
|
1387
|
+
// "X.Y版本结束" = 下一个已知版本起点 − 1 分钟;若 X.Y 已是最新版本 → 结束时间未知(endTs=null,
|
|
1388
|
+
// 仍算成立:活动在跑,只是没有绝对结束日)→ 由排序规则沉底,不跳过、不丢弃。
|
|
1389
|
+
const windowAt = (startText, endText) => {
|
|
1390
|
+
let startTs = null;
|
|
1391
|
+
const sv = startText.match(/(\d+\.\d+)\s*版本更新后/);
|
|
1392
|
+
if (sv) {
|
|
1393
|
+
if (verStart[sv[1]] == null) return null; // 该版本尚未开始 → 活动还没上线
|
|
1394
|
+
startTs = verStart[sv[1]];
|
|
1395
|
+
} else {
|
|
1396
|
+
const p = parseTime(startText);
|
|
1397
|
+
if (p.ts == null) return null;
|
|
1398
|
+
startTs = p.ts;
|
|
1399
|
+
}
|
|
1400
|
+
if (startTs > now) return null;
|
|
1401
|
+
let endTs = null;
|
|
1402
|
+
const ev = endText.match(/(\d+\.\d+)\s*版本结束/);
|
|
1403
|
+
if (ev) {
|
|
1404
|
+
if (verStart[ev[1]] == null) return null; // 版本未知 → 无法判定,保守略过
|
|
1405
|
+
const later = versions.find((v) => verStart[v] > verStart[ev[1]]);
|
|
1406
|
+
if (later != null) endTs = verStart[later] - 60000;
|
|
1407
|
+
} else {
|
|
1408
|
+
const p = parseTime(endText);
|
|
1409
|
+
if (p.ts == null) return null;
|
|
1410
|
+
endTs = p.ts;
|
|
1411
|
+
}
|
|
1412
|
+
if (endTs != null && endTs < now) return null;
|
|
1413
|
+
return { startTs, endTs };
|
|
1414
|
+
};
|
|
1415
|
+
// 标题筛选用「活动说明」:正文里带活动时间的都是这类;商城/城募/剧情/频段公告不在此列
|
|
1416
|
+
const byName = new Map();
|
|
1417
|
+
for (const it of list) {
|
|
1418
|
+
const title = clean(it?.sTitle);
|
|
1419
|
+
if (!/活动说明/.test(title)) continue;
|
|
1420
|
+
const name = (title.match(/^「([^」]+)」/) || [])[1] || title.replace(/活动说明$/, "").trim();
|
|
1421
|
+
if (!name || byName.has(name)) continue;
|
|
1422
|
+
const text = clean(it.sIntro) + " " + clean(it.sContent);
|
|
1423
|
+
for (const m of text.matchAll(ZZZ_EVENT_WINDOW_RE)) {
|
|
1424
|
+
const w = windowAt(m[1], m[2]);
|
|
1425
|
+
if (!w) continue; // 这个窗口此刻不成立 → 看下一个窗口
|
|
1426
|
+
byName.set(name, { banner: name, name, cat: "", ...w, raw: `${m[1]} ~ ${m[2]}` });
|
|
1427
|
+
break; // 一篇公告只取第一个成立的窗口
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
const active = sortEventItems([...byName.values()]);
|
|
1431
|
+
if (active.length === 0) return null;
|
|
1432
|
+
const primary = pickEventPrimary(active) || active[0];
|
|
1433
|
+
const dates = primary.startTs != null && primary.endTs != null ? fmtWindow(primary.startTs, primary.endTs) : (primary.raw || "");
|
|
1434
|
+
return {
|
|
1435
|
+
event: primary.name,
|
|
1436
|
+
eventDates: dates,
|
|
1437
|
+
eventDatesRaw: primary.raw || "",
|
|
1438
|
+
// 只有 1 条时 buildEventHover 返回 "",由 UI 退回单条展示(与其它源一致)
|
|
1439
|
+
eventHover: buildEventHover(active)
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function fetchZzzEventsOfficial(listUrl, signal) {
|
|
1444
|
+
const payload = await proxyFetchJson(listUrl, "https://zzz.mihoyo.com/");
|
|
1445
|
+
return parseZzzEventsOfficial(payload);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1352
1448
|
// 鸣潮:活动日历页 → font-size:17px 标题 + font-size:11px 时间,选当期
|
|
1353
1449
|
// 注意:复用 selectCurrent 需要 isMain 字段(该函数按 isMain 过滤主池)
|
|
1354
1450
|
function parseWuwaCalendar(html) {
|
|
@@ -1461,11 +1557,11 @@ export function createEngine(env) {
|
|
|
1461
1557
|
}
|
|
1462
1558
|
|
|
1463
1559
|
// 从 canmoe chunk JS 提取当期卡池:
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1560
|
+
// ① 当期角色窗口形如 d={梨诺:{windows:[{start,end,version,period,isRerun}]}},u=[...]
|
|
1561
|
+
// (注意:d 在 canmoe 侧可能长期不更新,只能当"覆盖当前时刻才采信"的快速路径)
|
|
1562
|
+
// ② 期次列表形如 <变量>=[{id,title,subtitle,version,periodStart,periodEnd,featured:[...]}],
|
|
1563
|
+
// 变量名随构建变化(曾见 p= / 现为 f=),由 extractCanmoePeriods 按内容定位。
|
|
1564
|
+
// 只采用"时间窗口覆盖当前时刻"的条目;无法识别返回 null。
|
|
1469
1565
|
function currentFromCanmoe(js, now) {
|
|
1470
1566
|
now = now || nowMs();
|
|
1471
1567
|
const fmt = (iso) => {
|
|
@@ -1484,7 +1580,7 @@ export function createEngine(env) {
|
|
|
1484
1580
|
}
|
|
1485
1581
|
}
|
|
1486
1582
|
// 2) p 数组中的"当前进行中"条目(过期当期后以此为兜底)
|
|
1487
|
-
const arr =
|
|
1583
|
+
const arr = extractCanmoePeriods(js);
|
|
1488
1584
|
if (arr) {
|
|
1489
1585
|
for (const e of arr) {
|
|
1490
1586
|
const ps = (e.match(/periodStart\s*:\s*"([^"]*)"/) || [])[1];
|
|
@@ -1503,10 +1599,16 @@ export function createEngine(env) {
|
|
|
1503
1599
|
return null;
|
|
1504
1600
|
}
|
|
1505
1601
|
|
|
1506
|
-
// 提取 canmoe chunk
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1602
|
+
// 提取 canmoe chunk 里的"卡池期次数组"(元素含 periodStart/periodEnd 的那个),返回元素子串数组。
|
|
1603
|
+
// 数组的变量名是压缩产物的一部分:canmoe 每次重新构建都可能改名(曾见 p=[…],现为 f=[…]),
|
|
1604
|
+
// 所以按"任意 `名字=[` 且数组体里有 periodStart"来定位,不写死变量名
|
|
1605
|
+
// (曾因写死 p= 而在 canmoe 改版后静默抓不到当期卡池)。
|
|
1606
|
+
function extractCanmoePeriods(js) {
|
|
1607
|
+
const re = /[A-Za-z_$][\w$]*\s*=\s*\[/g;
|
|
1608
|
+
for (let m = re.exec(js); m; m = re.exec(js)) {
|
|
1609
|
+
const start = js.indexOf("[", m.index);
|
|
1610
|
+
// 便宜预筛:数组开头不远处就有 periodStart 字段,省掉对每个数组都做括号平衡扫描
|
|
1611
|
+
if (!/periodStart\s*:/.test(js.slice(start, start + 4000))) continue;
|
|
1510
1612
|
let depth = 0, inStr = false, q = "", end = -1;
|
|
1511
1613
|
for (let i = start; i < js.length; i++) {
|
|
1512
1614
|
const ch = js[i];
|
|
@@ -1517,7 +1619,7 @@ export function createEngine(env) {
|
|
|
1517
1619
|
}
|
|
1518
1620
|
if (end < 0) continue;
|
|
1519
1621
|
const body = js.slice(start + 1, end);
|
|
1520
|
-
if (!/periodStart
|
|
1622
|
+
if (!/periodStart\s*:/.test(body)) continue;
|
|
1521
1623
|
const out = [];
|
|
1522
1624
|
let d2 = 0, s2 = false, q2 = "", st = 0;
|
|
1523
1625
|
for (let k = 0; k < body.length; k++) {
|
|
@@ -1563,7 +1665,7 @@ export function createEngine(env) {
|
|
|
1563
1665
|
// 终末地卡池列悬停:canmoe 卡池日历 chunk 内同期全部卡池条目(特许寻访 / 重构寻访 等),
|
|
1564
1666
|
// 每池"卡池名:角色"一行 + 时间;窗口相同则合并时间;结束时间升序(0/1 池返回 "" 走单条兜底)
|
|
1565
1667
|
function canmoePoolHover(js, now) {
|
|
1566
|
-
const arr =
|
|
1668
|
+
const arr = extractCanmoePeriods(js);
|
|
1567
1669
|
if (!arr) return "";
|
|
1568
1670
|
const pools = [];
|
|
1569
1671
|
for (const e of arr) {
|
|
@@ -1635,17 +1737,52 @@ export function createEngine(env) {
|
|
|
1635
1737
|
}
|
|
1636
1738
|
|
|
1637
1739
|
// 异环(官网 yh.wanmei.com 公告,经 host 代理):抓游戏公告列表 → 取最新维护/更新公告 → 解析当期限定棋盘卡池与限时活动
|
|
1740
|
+
// 官网公告列表条目:<a href="/news/gamebroad/YYYYMMDD/N.html">…<h2 class="title">标题</h2>
|
|
1741
|
+
const NTE_ITEM_RE = /<a href="(\/news\/gamebroad\/\d+\/\d+\.html)"[\s\S]*?<h2 class="title">([^<]+)<\/h2>/g;
|
|
1742
|
+
// 带新卡池的公告标题:停服维护/版本更新;"1.3版本「…」更新公告"这类版本名夹在中间,所以"更新公告"也要算
|
|
1743
|
+
const NTE_MAINT_RE = /停服维护|停服更新|维护公告|版本更新|更新公告/;
|
|
1744
|
+
// 逐页向下的上限:维护公告会随新公告发布被挤到第 2、3 页,只看第 1 页会把"进行中的卡池"误判成未公布
|
|
1745
|
+
const NTE_MAX_LIST_PAGES = 3;
|
|
1746
|
+
// 每次最多试几篇公告正文(按从新到旧),避免某篇规则失效时白抓一堆
|
|
1747
|
+
const NTE_MAX_DETAILS = 3;
|
|
1748
|
+
|
|
1749
|
+
// 取分页控件里的后续页地址(相对当前页):<ul class="pagination"> … <a href="index1.html">2</a>
|
|
1750
|
+
function nteNextPageUrls(listUrl, html, seen) {
|
|
1751
|
+
const pg = String(html || "").match(/<ul class="pagination">[\s\S]*?<\/ul>/);
|
|
1752
|
+
if (!pg) return [];
|
|
1753
|
+
const dir = listUrl.split("#")[0].split("?")[0].replace(/[^/]*$/, "");
|
|
1754
|
+
const out = [];
|
|
1755
|
+
for (const m of pg[0].matchAll(/href="(index\d+\.html)"/g)) {
|
|
1756
|
+
const u = dir + m[1];
|
|
1757
|
+
if (u !== listUrl && !seen.has(u) && !out.includes(u)) out.push(u);
|
|
1758
|
+
}
|
|
1759
|
+
return out;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// 逐页(index.html → index1.html → index2.html …)从新到旧找"带新卡池"的维护/版本更新公告,
|
|
1763
|
+
// 取第一篇能解析出当期卡池/活动的正文;"不停服更新"不含新卡池,跳过。
|
|
1764
|
+
// 找不到(含试满上限)返回 null → 调用方按"新卡池未公布"记。
|
|
1638
1765
|
async function fetchNteWanmei(listUrl, signal) {
|
|
1639
1766
|
const ref = "https://yh.wanmei.com/";
|
|
1640
|
-
const
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1767
|
+
const seen = new Set();
|
|
1768
|
+
const queue = [listUrl];
|
|
1769
|
+
let details = 0;
|
|
1770
|
+
while (queue.length && seen.size < NTE_MAX_LIST_PAGES) {
|
|
1771
|
+
const url = queue.shift();
|
|
1772
|
+
if (seen.has(url)) continue;
|
|
1773
|
+
seen.add(url);
|
|
1774
|
+
const html = await proxyFetchText(url, ref);
|
|
1775
|
+
// 列表条目本身从新到旧:边收集边试,命中当期内容立刻返回
|
|
1776
|
+
for (const m of html.matchAll(NTE_ITEM_RE)) {
|
|
1777
|
+
if (!NTE_MAINT_RE.test(m[2]) || /不停服/.test(m[2])) continue;
|
|
1778
|
+
if (details >= NTE_MAX_DETAILS) return null;
|
|
1779
|
+
details++;
|
|
1780
|
+
const data = parseNteWanmei(await proxyFetchText("https://yh.wanmei.com" + m[1], ref));
|
|
1781
|
+
if (data) return data;
|
|
1782
|
+
}
|
|
1783
|
+
for (const u of nteNextPageUrls(listUrl, html, seen)) if (!queue.includes(u)) queue.push(u);
|
|
1784
|
+
}
|
|
1785
|
+
return null;
|
|
1649
1786
|
}
|
|
1650
1787
|
|
|
1651
1788
|
// 解析官网公告正文 → 当期卡池(全新限定S级角色所属限定棋盘)+ 当期活动(限时活动)
|
|
@@ -2871,9 +3008,11 @@ export function createEngine(env) {
|
|
|
2871
3008
|
hsr: {
|
|
2872
3009
|
default: mkMediaWiki(genericEventPayload)
|
|
2873
3010
|
},
|
|
2874
|
-
//
|
|
3011
|
+
// 绝区零:默认=官方公告(api-takumi-static,与卡池侧同一接口;活动时间写在公告正文里,
|
|
3012
|
+
// 含"X.Y版本更新后/版本结束"的换算;结束时间未知的活动保留并沉底)→ 备选=Bwiki 活动一览
|
|
2875
3013
|
zzz: {
|
|
2876
|
-
default:
|
|
3014
|
+
default: (url, signal) => fetchZzzEventsOfficial(url, signal),
|
|
3015
|
+
"zzz-event-bwiki": mkMediaWiki(genericEventPayload)
|
|
2877
3016
|
},
|
|
2878
3017
|
// 明日方舟:PRTS 活动一览(「活动开始时间」表 + data-time 起止时间戳)→ 同上
|
|
2879
3018
|
arknights: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gacha-calendar-core",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.12",
|
|
4
4
|
"description": "二游活动/卡池排期抓取核心:11 款游戏的来源注册表 + 解析器 + 刷新编排 + 缓存降级。零依赖、零宿主环境依赖,任何 JS 运行时(浏览器扩展 background / Tauri / JavaScriptCore / ArkTS 嵌入式引擎)都能跑。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./core.mjs",
|