gacha-calendar-core 0.9.39 → 0.10.0

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 (2) hide show
  1. package/core.mjs +93 -16
  2. package/package.json +1 -1
package/core.mjs CHANGED
@@ -4,11 +4,17 @@
4
4
  // 用途:缓存里记录"这份数据是哪版插件产出的"。更新插件后首次启动,据此**强制**刷新一次
5
5
  // (不看自动刷新开关)——因为有些改动(悬停格式、来源地址、样式、解析器)不刷新就看不到效果。
6
6
  // 写入时机见 engine-api.js 的 refresh();判定见 60-helpers.js 的 autoRefreshPlan()。
7
- const PLUGIN_VERSION = "0.9.39";
7
+ const PLUGIN_VERSION = "0.10.0";
8
8
  //#endregion
9
9
 
10
10
  //#region config
11
11
  const NS = "gacha-calendar";
12
+ // 本插件在 profile 里的条目 id 候选(用于解析设置表单,见 92-dsh-env.js 的 resolveSettingsEntryId)。
13
+ // DSH 0.1.7 起设置文档按**条目 id**寻址,而"插件名/条目 id/包名"三者未必同名:
14
+ // · cordis.patch.yml 里是 id: gacha-calendar(我们自己的 insert 声明)
15
+ // · 包名是 dsh-gacha-calendar
16
+ // 所以不写死单一字符串,按顺序试,谁能拿到表单就用谁。
17
+ const SETTINGS_ENTRY_IDS = [NS, "dsh-gacha-calendar"];
12
18
  // 刷新频率选项:按天(存分钟),与 host 端 Config.refreshMinutes 对应。
13
19
  // 档位对齐常见版本周期:14/21/28/35/42 天 —— 15≈蔚蓝档案的 14 天轮换、21=1999 半版本/3.6 整版本
14
20
  // 与异环当期、30≈方舟月度、42=米系与 1999 的整版本
@@ -239,7 +245,7 @@
239
245
  },
240
246
  {
241
247
  id: "r1999",
242
- parserVersion: 2,
248
+ parserVersion: 3,
243
249
  name: "重返未来:1999",
244
250
  icon: "https://play-lh.googleusercontent.com/LwcueZMBbLq6aELtqJVn61ToKkJUgxEO8O4KgK_5052hfYoDAglQJIzqSu8srUJeaOZwv36Qi5YKtsXZjo-JPg=s64",
245
251
  source: "\u5B98\u65B9\u516C\u544A",
@@ -292,6 +298,40 @@
292
298
  gacha: { fail: "卡池失败", nomatch: "新卡池未公布", nomatchUser: "未解析出内容" },
293
299
  event: { fail: "活动失败", nomatch: "新活动未公布", nomatchUser: "未解析出内容" }
294
300
  };
301
+
302
+ // —— 配置表单写入被拒时的归因(纯函数)——
303
+ // DSH 0.1.7 的 configForms 表单有四种"能读不能写"的状态,肉眼在界面上完全分不出来
304
+ // (控件点一下弹回原值、没有任何提示)。这里按快照如实说清是哪一种,让下次出问题能一眼定位:
305
+ // memory = 本次连接不是本机回环 → 宿主压根不接受持久化写入(只读)
306
+ // loading = 表单数据还没送到(点太早了)
307
+ // unavailable = 宿主没在 serve 本插件的条目(多半是条目 id 对不上 / 没进 describe)
308
+ // 其它 = 宿主明确拒绝(字段没进表单,或修订号冲突)
309
+ function formRejectHint(scope, key) {
310
+ var snap = null;
311
+ try { snap = scope && scope.getSnapshot ? scope.getSnapshot() : null; } catch (e) { snap = null; }
312
+ const status = snap && snap.status ? snap.status : "unknown";
313
+ const mode = snap && snap.mode ? snap.mode : "unknown";
314
+ const rev = snap && typeof snap.revision === "number" ? " (rev " + snap.revision + ")" : "";
315
+ const id = scope && scope.entryId ? scope.entryId : "(未解析)";
316
+ if (mode === "memory") return "本次连接不是本机,宿主不接受写入(只读模式)";
317
+ if (status === "loading") return "配置表单还没送到,请稍后重试";
318
+ if (status === "unavailable") return "宿主未 serve 条目「" + id + "」" + servedNamespacesText(scope.configForms);
319
+ return "宿主拒绝了该写入(表单状态 " + status + " / " + mode + rev + ")";
320
+ }
321
+
322
+ // 把宿主**真正 serve 的**设置 namespace 列出来。这一条是给"地址对不上"这类故障留的现场:
323
+ // 只说"没提供配置条目"定位不了,列出真实 id 就能一眼看出该用哪个。
324
+ function servedNamespacesText(configForms) {
325
+ try {
326
+ const mirror = configForms && typeof configForms.describe === "function" ? configForms.describe() : null;
327
+ const view = mirror && typeof mirror.getSnapshot === "function" ? mirror.getSnapshot().view : null;
328
+ const rows = view && Array.isArray(view.namespaces) ? view.namespaces : [];
329
+ const names = rows.map((r) => r && r.ns).filter((ns) => typeof ns === "string");
330
+ return names.length ? "(宿主当前 serve:" + names.join(", ") + ")" : "(宿主当前一个设置条目都没 serve)";
331
+ } catch (e) {
332
+ return "";
333
+ }
334
+ }
295
335
  function normErr(err) {
296
336
  const name = String((err && err.name) || "");
297
337
  const msg = String((err && err.message) || err || "");
@@ -3092,7 +3132,31 @@ export function createEngine(env) {
3092
3132
  return "";
3093
3133
  }
3094
3134
 
3095
- // 解析一篇「版本活动一览」→ { pools, events, rotations }
3135
+ // 独立池公告:标题形如「池名」类型开启!,正文只有一张图(拿不到 UP 名单),
3136
+ // 但接口的 beginTime/endTime 就是该池的真实起止。官方**逐池各发一篇**,且**过期即从公告板下线**:
3137
+ // 实测公告板上 22 条无一条过期,已结束的池与其轮换期全都没有公告("有公告" ⟺ "该池在开")。
3138
+ // 所以轮换征集只能靠它——一览里那三行日期不带时间,过期后连日期行也会随版本一起消失。
3139
+ const R99_SOLO_POOL = /^\u300c([^\u300d]{1,40})\u300d([^\u300c\u300d]{1,12})\u5f00\u542f\uff01$/;
3140
+ function r99SoloPools(items) {
3141
+ const out = [];
3142
+ for (const it of items) {
3143
+ const title = String((it.contentMap && it.contentMap["zh-CN"] && it.contentMap["zh-CN"].title) || "").trim();
3144
+ const m = title.match(R99_SOLO_POOL);
3145
+ if (!m) continue;
3146
+ const kind = m[2].trim();
3147
+ if (R99_POOL_TIERS.indexOf(kind) < 0) continue; // 衣着上新/上架/上新等不是卡池
3148
+ const startTs = Number(it.beginTime);
3149
+ const endTs = Number(it.endTime) - 6e4; // 接口边界是排他的(…05:00),面板统一显示末分钟 04:59
3150
+ if (!startTs || !endTs) continue;
3151
+ out.push({
3152
+ name: `\u300c${m[1]}\u300d${kind}`, kind, roles: "", startTs, endTs, raw: "",
3153
+ display: fmtWindow(startTs, endTs)
3154
+ });
3155
+ }
3156
+ return out;
3157
+ }
3158
+
3159
+ // 解析一篇「版本活动一览」→ { pools, events, rotRows }
3096
3160
  function parseR99Overview(item, now = nowMs()) {
3097
3161
  const lines = r99NoticeLines(item);
3098
3162
  if (!lines) return null;
@@ -3147,20 +3211,16 @@ export function createEngine(env) {
3147
3211
  seenEvent.add(key);
3148
3212
  events.push({ name: sec.name, cat: sec.kind, startTs, endTs, raw: fmtWindow(startTs, endTs) });
3149
3213
  }
3150
- // 轮换征集:一览以「X月X日更新:角色、角色」逐期公布(14 天一期,末日 04:59)
3151
- const rotations = [];
3214
+ // 轮换征集在一览里**只有日期行**(「N月N日更新:角色」,不带起止时间)→ 这里只当"角色字典"。
3215
+ // 时间不再用"+14 天"推算:每期轮换都有自己的独立公告(见 r99SoloPools),接口给的就是真实起止。
3216
+ const rotRows = [];
3152
3217
  for (const line of lines) {
3153
3218
  const m = line.match(/^(\d{1,2})\u6708(\d{1,2})\u65e5\u66f4\u65b0[\uff1a:]\s*(.+)$/);
3154
3219
  if (!m) continue;
3155
- const startTs = new Date(year, Number(m[1]) - 1, Number(m[2]), 5, 0).getTime();
3156
- const endTs = startTs + 14 * 864e5 - 6e4;
3157
- rotations.push({
3158
- name: "\u300c\u8f6e\u6362\u5f81\u96c6\u300d", kind: "\u8f6e\u6362\u5f81\u96c6", roles: cleanRoles(m[3]),
3159
- startTs, endTs, raw: `\u3010${m[1]}\u6708${m[2]}\u65e5\u66f4\u65b0\u3011${cleanRoles(m[3])}`,
3160
- display: fmtWindow(startTs, endTs)
3161
- });
3220
+ // 只用"更新日"当匹配键(与同一期独立公告的 startTs 同一天),不作为时间来源
3221
+ rotRows.push({ startTs: new Date(year, Number(m[1]) - 1, Number(m[2]), 5, 0).getTime(), roles: cleanRoles(m[3]) });
3162
3222
  }
3163
- return { pools, events, rotations };
3223
+ return { pools, events, rotRows };
3164
3224
  }
3165
3225
 
3166
3226
  // 官方游戏内公告 → 当期征集(含真实起止、悬停列全部并行)+ 当期活动(同其它游戏的活动列规则)。
@@ -3173,7 +3233,7 @@ export function createEngine(env) {
3173
3233
  const titleOf = (it) => String((it.contentMap && it.contentMap["zh-CN"] && it.contentMap["zh-CN"].title) || "");
3174
3234
  const overviews = items.filter((it) => /\u7248\u672c\u6d3b\u52a8\u4e00\u89c8/.test(titleOf(it)));
3175
3235
  if (overviews.length === 0) throw new Error("r1999-notice-no-section");
3176
- const pools = [], events = [], rotations = [];
3236
+ const pools = [], events = [], rotRows = [];
3177
3237
  let parsed = 0;
3178
3238
  for (const it of overviews) {
3179
3239
  const o = parseR99Overview(it, now);
@@ -3181,11 +3241,27 @@ export function createEngine(env) {
3181
3241
  parsed++;
3182
3242
  pools.push.apply(pools, o.pools);
3183
3243
  events.push.apply(events, o.events);
3184
- rotations.push.apply(rotations, o.rotations);
3244
+ rotRows.push.apply(rotRows, o.rotRows);
3185
3245
  }
3186
3246
  if (parsed === 0) throw new Error("r1999-notice-no-dates");
3247
+ // 用独立公告补齐一览没有的池(当前就是轮换征集)。同名池**以一览为准**——它有正文【征集时间】
3248
+ // 与 UP 名单;独立公告正文只有图,只能补"池名 + 真实起止",角色另从日期行字典里按同一天取。
3249
+ const dayKey = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; };
3250
+ const rolesByDay = new Map(rotRows.map((r) => [dayKey(r.startTs), r.roles]));
3251
+ for (const p of r99SoloPools(items)) {
3252
+ if (pools.some((x) => x.name === p.name)) continue;
3253
+ p.roles = rolesByDay.get(dayKey(p.startTs)) || "";
3254
+ pools.push(p);
3255
+ }
3187
3256
  const data = {};
3188
- const active = pools.concat(rotations).filter((p) => p.startTs <= now && p.endTs >= now);
3257
+ // 当期 = 窗口覆盖现在的池;同名多期(一览的下一期 + 公告的当期)只留第一个 = 一览优先
3258
+ const seenName = new Set();
3259
+ const active = pools.filter((p) => {
3260
+ if (!(p.startTs <= now && p.endTs >= now)) return false;
3261
+ if (seenName.has(p.name)) return false;
3262
+ seenName.add(p.name);
3263
+ return true;
3264
+ });
3189
3265
  if (active.length > 0) {
3190
3266
  const rank = (p) => {
3191
3267
  const i = R99_POOL_TIERS.indexOf(p.kind);
@@ -3809,6 +3885,7 @@ export function createEngine(env) {
3809
3885
  DEFAULT_SETTINGS,
3810
3886
  // 启动自动刷新判定(纯函数)与当前插件版本(注入自 package.json):回归脚本据此验收
3811
3887
  autoRefreshPlan,
3888
+ formRejectHint,
3812
3889
  PLUGIN_VERSION
3813
3890
  };
3814
3891
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gacha-calendar-core",
3
- "version": "0.9.39",
3
+ "version": "0.10.0",
4
4
  "description": "二游活动/卡池排期抓取核心:11 款游戏的来源注册表 + 解析器 + 刷新编排 + 缓存降级。零依赖、零宿主环境依赖,任何 JS 运行时(浏览器扩展 background / Tauri / JavaScriptCore / ArkTS 嵌入式引擎)都能跑。",
5
5
  "type": "module",
6
6
  "main": "./core.mjs",