koishi-plugin-anime-schedule 1.1.2 → 1.1.3
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/lib/data/anilist-match.js +5 -1
- package/lib/handlers.js +15 -9
- package/lib/services/airing.d.ts +8 -0
- package/lib/services/airing.js +22 -5
- package/lib/services/digest.d.ts +2 -0
- package/lib/services/digest.js +28 -11
- package/lib/services/info.js +9 -3
- package/lib/services/localizer.d.ts +12 -1
- package/lib/services/localizer.js +45 -10
- package/lib/services/scheduler.js +5 -2
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +15 -6
- package/package.json +2 -2
|
@@ -59,7 +59,11 @@ function matchByTitle(target, candidates) {
|
|
|
59
59
|
continue;
|
|
60
60
|
const startMs = startMsOf(candidate);
|
|
61
61
|
const dayGap = startMs !== null && beginMs !== null ? Math.abs(startMs - beginMs) / DAY : null;
|
|
62
|
-
|
|
62
|
+
// 标题相等只说明是同一部作品,不能排除同名续作;首播日期缺失时无法校验,直接放弃,
|
|
63
|
+
// 否则「第 2 期」这类同名条目会被错配到前一季。
|
|
64
|
+
if (dayGap === null)
|
|
65
|
+
continue;
|
|
66
|
+
if (dayGap > exports.MAX_DAY_GAP)
|
|
63
67
|
continue;
|
|
64
68
|
const nodes = (candidate.airingSchedule?.nodes ?? []).filter((node) => typeof node.airingAt === 'number');
|
|
65
69
|
const first = nodes.length
|
package/lib/handlers.js
CHANGED
|
@@ -380,13 +380,9 @@ async function commandSubList(deps, session) {
|
|
|
380
380
|
const upcoming = await digest.upcomingAirings(tids, 31);
|
|
381
381
|
// `upcomingAirings` 会滤掉已播完的作品,故单独查询一次用于标注「已播完」
|
|
382
382
|
const finished = new Set(await digest.finishedSubscriptionTids(tids));
|
|
383
|
-
const names =
|
|
383
|
+
const names = await digest.resolveSubscriptionNames(tids);
|
|
384
384
|
const nextOf = new Map();
|
|
385
385
|
const todayOf = new Map();
|
|
386
|
-
for (const row of rows) {
|
|
387
|
-
const resolved = await digest.resolveSubscriptionName(row.tid);
|
|
388
|
-
names.set(row.tid, resolved ?? row.title ?? row.tid);
|
|
389
|
-
}
|
|
390
386
|
for (const item of upcoming) {
|
|
391
387
|
if (!nextOf.has(item.tid))
|
|
392
388
|
nextOf.set(item.tid, item);
|
|
@@ -508,7 +504,8 @@ async function commandSubClear(deps, session) {
|
|
|
508
504
|
/** 查询订阅作品未来若干天的播出计划 */
|
|
509
505
|
async function commandNext(deps, session, daysText) {
|
|
510
506
|
const { config, digest } = deps;
|
|
511
|
-
const
|
|
507
|
+
const parsed = Number.parseInt(daysText ?? '7', 10);
|
|
508
|
+
const days = (0, utils_1.clamp)(Number.isFinite(parsed) ? parsed : 7, 1, 31);
|
|
512
509
|
try {
|
|
513
510
|
const rows = await digest.listSubscriptions(platformOf(session), channelOf(session));
|
|
514
511
|
if (!rows.length)
|
|
@@ -545,7 +542,7 @@ function commandOff(deps, session, kindText) {
|
|
|
545
542
|
return togglePush(deps, session, kindText, false);
|
|
546
543
|
}
|
|
547
544
|
async function togglePush(deps, session, kindText, enabled) {
|
|
548
|
-
const { config, digest } = deps;
|
|
545
|
+
const { ctx, config, digest } = deps;
|
|
549
546
|
if (!session.guildId)
|
|
550
547
|
return constants_1.RESPONSES.needGuild;
|
|
551
548
|
const raw = (kindText ?? '').trim().toLowerCase();
|
|
@@ -569,6 +566,13 @@ async function togglePush(deps, session, kindText, enabled) {
|
|
|
569
566
|
enabled: true,
|
|
570
567
|
});
|
|
571
568
|
}
|
|
569
|
+
else if (existing.botId !== session.selfId || existing.guildId !== session.guildId) {
|
|
570
|
+
// 机器人换号或频道迁移后,开播提醒要能找到当前机器人;顺手刷新归属信息
|
|
571
|
+
await ctx.database.set('anime_channel', {
|
|
572
|
+
platform: platformOf(session),
|
|
573
|
+
channelId: channelOf(session),
|
|
574
|
+
}, { botId: session.selfId, guildId: session.guildId });
|
|
575
|
+
}
|
|
572
576
|
await digest.setKinds(platformOf(session), channelOf(session), kind === 'daily' ? { dailyEnabled: enabled }
|
|
573
577
|
: kind === 'night' ? { nightEnabled: enabled }
|
|
574
578
|
: kind === 'weekly' ? { weeklyEnabled: enabled }
|
|
@@ -592,11 +596,13 @@ async function commandStatus(deps, session) {
|
|
|
592
596
|
try {
|
|
593
597
|
const channel = await digest.getChannel(platformOf(session), channelOf(session));
|
|
594
598
|
const subscriptions = await digest.listSubscriptions(platformOf(session), channelOf(session));
|
|
599
|
+
const dayStart = (0, time_1.getDayStartMs)((0, time_1.today)(digest.timeZone), digest.timeZone);
|
|
600
|
+
// 只取今天的记录;以前是全量取回再在内存里过滤,推送记录攒久后会越来越慢
|
|
595
601
|
const dispatches = await ctx.database.get('anime_dispatch', {
|
|
596
602
|
platform: platformOf(session),
|
|
597
603
|
channelId: channelOf(session),
|
|
604
|
+
dispatchedAt: { $gte: dayStart, $lt: dayStart + 86400000 },
|
|
598
605
|
});
|
|
599
|
-
const dayStart = (0, time_1.getDayStartMs)((0, time_1.today)(digest.timeZone), digest.timeZone);
|
|
600
606
|
const formatText = config.output.format === 'image'
|
|
601
607
|
? `图片 (${config.output.layout === 'list' ? '卡片列表' : '时间表'})`
|
|
602
608
|
: `纯文本${config.output.useForward ? ' (合并转发)' : ''}`;
|
|
@@ -611,7 +617,7 @@ async function commandStatus(deps, session) {
|
|
|
611
617
|
`订阅番剧: ${subscriptions.length} 部${config.subscription.maxPerChannel > 0 ? ` (上限 ${config.subscription.maxPerChannel})` : ''}`,
|
|
612
618
|
`开播提醒: ${config.push.updateReminderEnabled ? `开,提前 ${config.push.updateLookaheadMinutes} 分钟` : '关'}`,
|
|
613
619
|
// 按记录时间统计:`sub`(开播提醒)的去重键里带的是时刻而不是日期,不能靠键判断
|
|
614
|
-
`今日推送记录: ${dispatches.
|
|
620
|
+
`今日推送记录: ${dispatches.length} 条`,
|
|
615
621
|
].join('\n');
|
|
616
622
|
}
|
|
617
623
|
catch (error) {
|
package/lib/services/airing.d.ts
CHANGED
|
@@ -44,6 +44,14 @@ export declare class AiringStatusService {
|
|
|
44
44
|
/** 无需预热(缓存只在内存里),保留此方法让调用方写法统一 */
|
|
45
45
|
ensure(): Promise<void>;
|
|
46
46
|
private isFresh;
|
|
47
|
+
/**
|
|
48
|
+
* 标题缓存键。
|
|
49
|
+
*
|
|
50
|
+
* 不能只用折叠标题:`normalizeTitle` 会删掉「第 2 期」这类季别,不同季的同名作品
|
|
51
|
+
* 会命中同一个 ID。把开播日期(取 `beginMs`,缺失时用 `broadcastMs`)按天拼进去,
|
|
52
|
+
* 同名但不同季/不同年的作品就能区分开。
|
|
53
|
+
*/
|
|
54
|
+
private titleKey;
|
|
47
55
|
/**
|
|
48
56
|
* 按标题给缺失 `aniList` ID 的条目补一个 ID:补上就用 AniList 的逐話時刻排期,搜不到返回
|
|
49
57
|
* null 让调用方退回周期推算。结果(含「搜不到」)在进程内缓存。
|
package/lib/services/airing.js
CHANGED
|
@@ -79,8 +79,22 @@ class AiringStatusService {
|
|
|
79
79
|
ensure() {
|
|
80
80
|
return Promise.resolve();
|
|
81
81
|
}
|
|
82
|
-
isFresh(entry, ttlDays) {
|
|
83
|
-
return
|
|
82
|
+
isFresh(entry, ttlDays, now = Date.now()) {
|
|
83
|
+
return now - entry.cachedAt < Math.max(0, ttlDays) * 24 * 60 * 60 * 1000;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 标题缓存键。
|
|
87
|
+
*
|
|
88
|
+
* 不能只用折叠标题:`normalizeTitle` 会删掉「第 2 期」这类季别,不同季的同名作品
|
|
89
|
+
* 会命中同一个 ID。把开播日期(取 `beginMs`,缺失时用 `broadcastMs`)按天拼进去,
|
|
90
|
+
* 同名但不同季/不同年的作品就能区分开。
|
|
91
|
+
*/
|
|
92
|
+
titleKey(target) {
|
|
93
|
+
const title = (0, anilist_match_1.normalizeTitle)(target.title);
|
|
94
|
+
if (!title)
|
|
95
|
+
return '';
|
|
96
|
+
const anchor = target.beginMs ?? target.broadcastMs ?? null;
|
|
97
|
+
return anchor === null ? title : `${title}@${Math.floor(anchor / 86400000)}`;
|
|
84
98
|
}
|
|
85
99
|
/**
|
|
86
100
|
* 按标题给缺失 `aniList` ID 的条目补一个 ID:补上就用 AniList 的逐話時刻排期,搜不到返回
|
|
@@ -88,7 +102,7 @@ class AiringStatusService {
|
|
|
88
102
|
* 校验交给 `matchByTitle`:标题相等 + 首播日期相近,缺一不可。
|
|
89
103
|
*/
|
|
90
104
|
async resolveByTitle(target) {
|
|
91
|
-
const key =
|
|
105
|
+
const key = this.titleKey(target);
|
|
92
106
|
if (!key)
|
|
93
107
|
return null;
|
|
94
108
|
const cached = this.idByTitle.get(key);
|
|
@@ -123,7 +137,10 @@ class AiringStatusService {
|
|
|
123
137
|
return payload?.data?.Page?.media ?? [];
|
|
124
138
|
}
|
|
125
139
|
catch (error) {
|
|
126
|
-
|
|
140
|
+
// 只有「成功响应但没有候选」才是 [];网络/解析失败必须返回 null,
|
|
141
|
+
// 否则会被 resolveByTitle 当成「确定搜不到」永久写入 idByTitle。
|
|
142
|
+
this.handleRequestError(error);
|
|
143
|
+
return null;
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
/**
|
|
@@ -147,7 +164,7 @@ class AiringStatusService {
|
|
|
147
164
|
}
|
|
148
165
|
// 过期条目先返回旧值,避免一次请求失败就没有数据可用
|
|
149
166
|
result.set(id, cached);
|
|
150
|
-
if (!this.isFresh(cached, ttlDays))
|
|
167
|
+
if (!this.isFresh(cached, ttlDays, now))
|
|
151
168
|
wanted.push(id);
|
|
152
169
|
}
|
|
153
170
|
if (!wanted.length)
|
package/lib/services/digest.d.ts
CHANGED
|
@@ -280,6 +280,8 @@ export declare class DigestService {
|
|
|
280
280
|
* @param limit 返回条数上限,`<= 0` 表示不限制
|
|
281
281
|
*/
|
|
282
282
|
search(keyword: string, limit?: number): Promise<DigestItem[]>;
|
|
283
|
+
/** 按条目 ID 批量解析作品名;订阅列表一次取完,避免逐条串行请求 */
|
|
284
|
+
resolveSubscriptionNames(tids: string[]): Promise<Map<string, string | null>>;
|
|
283
285
|
/** 按条目 ID 解析作品名;同时接受 `253104` 与 `bgm:253104` 两种写法 */
|
|
284
286
|
resolveSubscriptionName(tid: string): Promise<string | null>;
|
|
285
287
|
}
|
package/lib/services/digest.js
CHANGED
|
@@ -1328,19 +1328,36 @@ class DigestService {
|
|
|
1328
1328
|
return this.toItem(candidate, entry.bangumiId !== null ? subjects.get(entry.bangumiId) : undefined);
|
|
1329
1329
|
});
|
|
1330
1330
|
}
|
|
1331
|
+
/** 按条目 ID 批量解析作品名;订阅列表一次取完,避免逐条串行请求 */
|
|
1332
|
+
async resolveSubscriptionNames(tids) {
|
|
1333
|
+
await this.prepare();
|
|
1334
|
+
const result = new Map();
|
|
1335
|
+
const entries = new Map();
|
|
1336
|
+
const idByTid = new Map();
|
|
1337
|
+
for (const tid of new Set(tids)) {
|
|
1338
|
+
const matched = BGM_TID_RE.exec(tid) ?? BGM_ID_RE.exec(tid);
|
|
1339
|
+
if (!matched) {
|
|
1340
|
+
result.set(tid, null);
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
const id = Number(matched[1]);
|
|
1344
|
+
const entry = this.index.bySubjectId(id);
|
|
1345
|
+
if (!entry) {
|
|
1346
|
+
result.set(tid, null);
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
entries.set(tid, entry);
|
|
1350
|
+
idByTid.set(tid, id);
|
|
1351
|
+
}
|
|
1352
|
+
const subjects = await this.localizer.localizeByIds([...new Set(idByTid.values())], false);
|
|
1353
|
+
for (const [tid, entry] of entries) {
|
|
1354
|
+
result.set(tid, this.toItem({ entry, atMs: this.nextAirMs(entry, Date.now()), episode: 1 }, subjects.get(idByTid.get(tid))).name);
|
|
1355
|
+
}
|
|
1356
|
+
return result;
|
|
1357
|
+
}
|
|
1331
1358
|
/** 按条目 ID 解析作品名;同时接受 `253104` 与 `bgm:253104` 两种写法 */
|
|
1332
1359
|
async resolveSubscriptionName(tid) {
|
|
1333
|
-
await this.
|
|
1334
|
-
const matched = BGM_TID_RE.exec(tid) ?? BGM_ID_RE.exec(tid);
|
|
1335
|
-
if (!matched)
|
|
1336
|
-
return null;
|
|
1337
|
-
const id = Number(matched[1]);
|
|
1338
|
-
const entry = this.index.bySubjectId(id);
|
|
1339
|
-
if (!entry)
|
|
1340
|
-
return null;
|
|
1341
|
-
// 只是为了拿一个展示用的名字,不需要逐集档期
|
|
1342
|
-
const subjects = await this.localizer.localizeByIds([id]);
|
|
1343
|
-
return this.toItem({ entry, atMs: this.nextAirMs(entry, Date.now()), episode: 1 }, subjects.get(id)).name;
|
|
1360
|
+
return (await this.resolveSubscriptionNames([tid])).get(tid) ?? null;
|
|
1344
1361
|
}
|
|
1345
1362
|
}
|
|
1346
1363
|
exports.DigestService = DigestService;
|
package/lib/services/info.js
CHANGED
|
@@ -398,10 +398,16 @@ exports.InfoService = InfoService;
|
|
|
398
398
|
function flattenSummary(summary) {
|
|
399
399
|
if (!summary)
|
|
400
400
|
return '';
|
|
401
|
-
|
|
401
|
+
const lines = summary
|
|
402
402
|
.replace(/\r\n?/g, '\n')
|
|
403
403
|
.split('\n')
|
|
404
404
|
.map((line) => line.trim())
|
|
405
|
-
.filter(Boolean)
|
|
406
|
-
|
|
405
|
+
.filter(Boolean);
|
|
406
|
+
// 中文/日文换行处不需要空格;英文单词在源文本里被折行时则需要补一个,
|
|
407
|
+
// 否则「the story」会被拼成「thestory」。
|
|
408
|
+
return lines.reduce((text, line) => {
|
|
409
|
+
if (!text)
|
|
410
|
+
return line;
|
|
411
|
+
return `${text}${/[!-~]$/.test(text) && /^[A-Za-z0-9]/.test(line) ? ' ' : ''}${line}`;
|
|
412
|
+
}, '');
|
|
407
413
|
}
|
|
@@ -45,6 +45,10 @@ export declare class Localizer {
|
|
|
45
45
|
private loadPromise;
|
|
46
46
|
private dirty;
|
|
47
47
|
private flushTimer;
|
|
48
|
+
/** 正在进行的写盘;并发调用共享同一个 Promise,避免两个 tmp 文件互相覆盖 */
|
|
49
|
+
private flushing;
|
|
50
|
+
/** 上一次写盘是否失败:失败后不立即重排定时器,避免磁盘故障时每 3 秒刷一条警告 */
|
|
51
|
+
private writeFailed;
|
|
48
52
|
/** 本次构建里评分过期、等出图之后在后台补取的条目 */
|
|
49
53
|
private readonly pendingScores;
|
|
50
54
|
private refreshingScores;
|
|
@@ -54,8 +58,15 @@ export declare class Localizer {
|
|
|
54
58
|
/** 确保磁盘缓存已读取(重复调用只读一次) */
|
|
55
59
|
ensure(): Promise<void>;
|
|
56
60
|
load(): Promise<void>;
|
|
57
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* 把缓存写回磁盘。
|
|
63
|
+
*
|
|
64
|
+
* 同一时刻只允许一个写盘:`save()` 与 3 秒防抖可能同时触发,两个写操作会争用同一个
|
|
65
|
+
* `.tmp` 文件。`dirty` 只在写入成功后才保持 `false`;失败时重新置位,等下一次
|
|
66
|
+
* `scheduleFlush()` 或 `save()` 再试,避免一次磁盘抖动就让本次更新永久丢失。
|
|
67
|
+
*/
|
|
58
68
|
private flush;
|
|
69
|
+
private writeCache;
|
|
59
70
|
/** 延迟合并写入,避免每次命中都落盘 */
|
|
60
71
|
private scheduleFlush;
|
|
61
72
|
/** 主动落盘,插件卸载与调度结束后调用 */
|
|
@@ -60,6 +60,10 @@ class Localizer {
|
|
|
60
60
|
loadPromise = null;
|
|
61
61
|
dirty = false;
|
|
62
62
|
flushTimer = null;
|
|
63
|
+
/** 正在进行的写盘;并发调用共享同一个 Promise,避免两个 tmp 文件互相覆盖 */
|
|
64
|
+
flushing = null;
|
|
65
|
+
/** 上一次写盘是否失败:失败后不立即重排定时器,避免磁盘故障时每 3 秒刷一条警告 */
|
|
66
|
+
writeFailed = false;
|
|
63
67
|
/** 本次构建里评分过期、等出图之后在后台补取的条目 */
|
|
64
68
|
pendingScores = new Set();
|
|
65
69
|
refreshingScores = false;
|
|
@@ -115,10 +119,27 @@ class Localizer {
|
|
|
115
119
|
})();
|
|
116
120
|
return this.loadPromise;
|
|
117
121
|
}
|
|
118
|
-
/**
|
|
119
|
-
|
|
122
|
+
/**
|
|
123
|
+
* 把缓存写回磁盘。
|
|
124
|
+
*
|
|
125
|
+
* 同一时刻只允许一个写盘:`save()` 与 3 秒防抖可能同时触发,两个写操作会争用同一个
|
|
126
|
+
* `.tmp` 文件。`dirty` 只在写入成功后才保持 `false`;失败时重新置位,等下一次
|
|
127
|
+
* `scheduleFlush()` 或 `save()` 再试,避免一次磁盘抖动就让本次更新永久丢失。
|
|
128
|
+
*/
|
|
129
|
+
flush() {
|
|
130
|
+
if (this.flushing)
|
|
131
|
+
return this.flushing;
|
|
120
132
|
if (!this.dirty)
|
|
121
|
-
return;
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
this.flushing = this.writeCache().finally(() => {
|
|
135
|
+
this.flushing = null;
|
|
136
|
+
// 写盘快照之后又有新条目:补一次合并写盘;失败时不自动重排,等下一次 save/schedule
|
|
137
|
+
if (this.dirty && !this.writeFailed)
|
|
138
|
+
this.scheduleFlush();
|
|
139
|
+
});
|
|
140
|
+
return this.flushing;
|
|
141
|
+
}
|
|
142
|
+
async writeCache() {
|
|
122
143
|
this.dirty = false;
|
|
123
144
|
if (this.memory.size > constants_1.LOCALIZE_CACHE_MAX_ENTRIES) {
|
|
124
145
|
const sorted = [...this.memory.entries()].sort((a, b) => a[1].cachedAt - b[1].cachedAt);
|
|
@@ -134,8 +155,12 @@ class Localizer {
|
|
|
134
155
|
await fs_1.promises.mkdir((0, path_1.dirname)(this.cachePath), { recursive: true });
|
|
135
156
|
await fs_1.promises.writeFile(temp, JSON.stringify(payload), 'utf8');
|
|
136
157
|
await fs_1.promises.rename(temp, this.cachePath);
|
|
158
|
+
this.writeFailed = false;
|
|
137
159
|
}
|
|
138
160
|
catch (error) {
|
|
161
|
+
// 写入期间可能又有新条目进来;失败后也保持 dirty,交给下一次写盘重试
|
|
162
|
+
this.dirty = true;
|
|
163
|
+
this.writeFailed = true;
|
|
139
164
|
this.logger.warn('[localize] 写入本地化缓存失败:', error);
|
|
140
165
|
await fs_1.promises.rm(temp, { force: true }).catch(() => undefined);
|
|
141
166
|
}
|
|
@@ -157,6 +182,9 @@ class Localizer {
|
|
|
157
182
|
this.flushTimer = null;
|
|
158
183
|
}
|
|
159
184
|
await this.flush();
|
|
185
|
+
// 写盘期间又产生了新条目:等当前写盘结束后再补一次
|
|
186
|
+
if (this.dirty && !this.flushing)
|
|
187
|
+
await this.flush();
|
|
160
188
|
}
|
|
161
189
|
/**
|
|
162
190
|
* 标题、封面与首末集日期是否还能用。这几项基本不变,只受 `cacheTtlDays` 管。
|
|
@@ -204,8 +232,10 @@ class Localizer {
|
|
|
204
232
|
result.set(id, cached);
|
|
205
233
|
if (this.needsScoreRefresh(cached))
|
|
206
234
|
this.pendingScores.add(id);
|
|
207
|
-
//
|
|
208
|
-
|
|
235
|
+
// 只有展示数据过期、或这次要档期而缓存里没有档期时才阻塞构建。
|
|
236
|
+
// 404 占位记录带 `hasEpisodeTimes:false`,但它的 TTL 内不该反复重试,由 isDisplayFresh 决定。
|
|
237
|
+
if (!this.isDisplayFresh(cached)
|
|
238
|
+
|| (wantEpisodeTimes && !cached.missing && cached.hasEpisodeTimes === false)) {
|
|
209
239
|
wanted.push(id);
|
|
210
240
|
previous.set(id, cached);
|
|
211
241
|
}
|
|
@@ -219,7 +249,7 @@ class Localizer {
|
|
|
219
249
|
//
|
|
220
250
|
// 已经取过档期的条目**不重复取**——评分每天刷新,而档期是另一笔请求,跟着一起重发
|
|
221
251
|
// 会让请求量凭空翻倍。只有「没取过」或「这次确实缺它」才补。
|
|
222
|
-
const needsTimes = wantEpisodeTimes && !(stale && stale.hasEpisodeTimes
|
|
252
|
+
const needsTimes = wantEpisodeTimes && !(stale && stale.hasEpisodeTimes === true);
|
|
223
253
|
const subject = await this.fetchSubject(id);
|
|
224
254
|
const times = needsTimes && subject && resolveEpisodeTotal(subject)
|
|
225
255
|
? await this.fetchEpisodeTimes(id)
|
|
@@ -291,17 +321,22 @@ class Localizer {
|
|
|
291
321
|
const subject = await this.fetchSubject(id);
|
|
292
322
|
if (!subject)
|
|
293
323
|
return;
|
|
324
|
+
// fetchSubject 是 await 点:期间 `localizeByIds` 可能刚写入更完整的档期字段。
|
|
325
|
+
// 这里必须基于「最新的一份」合并,不能拿请求前的快照整体覆盖。
|
|
326
|
+
const current = this.memory.get(id);
|
|
327
|
+
if (!current || current.missing || !this.needsScoreRefresh(current))
|
|
328
|
+
return;
|
|
294
329
|
ok++;
|
|
295
330
|
this.memory.set(id, {
|
|
296
|
-
...
|
|
331
|
+
...current,
|
|
297
332
|
title: subject.name_cn?.trim() || subject.name,
|
|
298
333
|
coverUrl: subject.images?.large ?? subject.images?.common ?? '',
|
|
299
334
|
score: computeScore(subject.rating),
|
|
300
335
|
ratingTotal: typeof subject.rating?.total === 'number' ? subject.rating.total : null,
|
|
301
336
|
totalEpisodes: resolveEpisodeTotal(subject),
|
|
302
|
-
hasEpisodeTimes:
|
|
303
|
-
firstAirMs:
|
|
304
|
-
lastAirMs:
|
|
337
|
+
hasEpisodeTimes: current.hasEpisodeTimes,
|
|
338
|
+
firstAirMs: current.firstAirMs,
|
|
339
|
+
lastAirMs: current.lastAirMs,
|
|
305
340
|
cachedAt: Date.now(),
|
|
306
341
|
scoreCachedAt: Date.now(),
|
|
307
342
|
});
|
|
@@ -63,7 +63,8 @@ class Scheduler {
|
|
|
63
63
|
try {
|
|
64
64
|
const due = this.dueKinds(now);
|
|
65
65
|
const reminder = this.config.push.updateReminderEnabled && this.reminderDue(now);
|
|
66
|
-
//
|
|
66
|
+
// 没有到期的推送就不碰数据库:清理与落盘只在真正有推送/提醒的轮次里跑。
|
|
67
|
+
// 提醒默认每 10 分钟轮询一次,因此缓存落盘与过期记录清理也随之变成约 10 分钟一次。
|
|
67
68
|
if (!due.length && !reminder)
|
|
68
69
|
return;
|
|
69
70
|
for (const kind of due)
|
|
@@ -200,7 +201,9 @@ class Scheduler {
|
|
|
200
201
|
: kind === 'night' ? 'nightEnabled'
|
|
201
202
|
: kind === 'weekly' ? 'weeklyEnabled'
|
|
202
203
|
: 'seasonEnabled';
|
|
203
|
-
|
|
204
|
+
const rows = await this.ctx.database.get('anime_channel', { [field]: true });
|
|
205
|
+
// `enabled` 是后加的字段,老数据的 null 不能被当成「已关闭」;显式 false 才跳过。
|
|
206
|
+
return rows.filter((row) => row.enabled !== false);
|
|
204
207
|
}
|
|
205
208
|
/**
|
|
206
209
|
* 订阅作品的开播提醒。场次由周期规则推算,与定时列表推送面向不同时间点,也不受频道的列表
|
package/lib/utils/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Logger } from 'koishi';
|
|
|
7
7
|
* 不展开就只能看到「fetch failed」,无法判断是 DNS、连接超时还是被重置。
|
|
8
8
|
*/
|
|
9
9
|
export declare function errorMessage(error: unknown): string;
|
|
10
|
-
/** 读取 HTTP 错误的状态码,取不到时返回 null */
|
|
10
|
+
/** 读取 HTTP 错误的状态码,取不到时返回 null。兼容 `response.status`、直接 `status` 与 `cause` 链 */
|
|
11
11
|
export declare function errorStatus(error: unknown): number | null;
|
|
12
12
|
/**
|
|
13
13
|
* 这个错误是不是「超时」(包括 undici 的连接超时与响应超时)。
|
package/lib/utils/index.js
CHANGED
|
@@ -57,13 +57,22 @@ function errorName(error) {
|
|
|
57
57
|
return String(error.name ?? '');
|
|
58
58
|
return '';
|
|
59
59
|
}
|
|
60
|
-
/** 读取 HTTP 错误的状态码,取不到时返回 null */
|
|
60
|
+
/** 读取 HTTP 错误的状态码,取不到时返回 null。兼容 `response.status`、直接 `status` 与 `cause` 链 */
|
|
61
61
|
function errorStatus(error) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
let current = error;
|
|
63
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
|
64
|
+
if (!current || typeof current !== 'object')
|
|
65
|
+
break;
|
|
66
|
+
const direct = current.status;
|
|
67
|
+
if (typeof direct === 'number')
|
|
68
|
+
return direct;
|
|
69
|
+
const response = current.response;
|
|
70
|
+
const status = response?.status;
|
|
71
|
+
if (typeof status === 'number')
|
|
72
|
+
return status;
|
|
73
|
+
current = current.cause;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
67
76
|
}
|
|
68
77
|
/**
|
|
69
78
|
* 这个错误是不是「超时」(包括 undici 的连接超时与响应超时)。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-anime-schedule",
|
|
3
3
|
"description": "番剧时间表与订阅提醒:提供日 / 周 / 季三表,支持定时推送、指定订阅、搜索和详情展示",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.3",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"typings": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@napi-rs/canvas": "^1.0.10",
|
|
41
|
-
"bangumi-data": "^0.3.
|
|
41
|
+
"bangumi-data": "^0.3.234"
|
|
42
42
|
},
|
|
43
43
|
"koishi": {
|
|
44
44
|
"description": {
|