koishi-plugin-anime-schedule 1.1.3 → 1.1.4
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/config.js +1 -1
- package/lib/constants.d.ts +14 -0
- package/lib/constants.js +15 -1
- package/lib/index.js +12 -6
- package/lib/services/digest.d.ts +10 -0
- package/lib/services/digest.js +110 -23
- package/lib/services/verdicts.d.ts +85 -0
- package/lib/services/verdicts.js +241 -0
- package/package.json +1 -1
- package/readme.md +3 -0
package/lib/config.js
CHANGED
|
@@ -171,7 +171,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
171
171
|
.description('请求失败重试次数')
|
|
172
172
|
.default(2),
|
|
173
173
|
debug: koishi_1.Schema.boolean()
|
|
174
|
-
.description('
|
|
174
|
+
.description('输出调试日志')
|
|
175
175
|
.default(false),
|
|
176
176
|
}),
|
|
177
177
|
}).description('其他'),
|
package/lib/constants.d.ts
CHANGED
|
@@ -95,6 +95,20 @@ export declare const MIN_SCORE_VOTES = 10;
|
|
|
95
95
|
export declare const LOCALIZE_CACHE_VERSION = 6;
|
|
96
96
|
/** 本地化缓存最大条目数,超出后按写入时间淘汰最旧的一半 */
|
|
97
97
|
export declare const LOCALIZE_CACHE_MAX_ENTRIES = 5000;
|
|
98
|
+
/**
|
|
99
|
+
* 「不采信 / 已剔除」判定缓存的版本与有效期。
|
|
100
|
+
*
|
|
101
|
+
* 这些判定的输入都是慢变量(bangumi-data 的 `aniList` ID、Bangumi 逐集档期),
|
|
102
|
+
* 但上游偶尔会修数据,因此不设永久缓存:AniList 判定 7 天后复核,Bangumi 档期判定
|
|
103
|
+
* 30 天后复核(与本地化缓存一致)。
|
|
104
|
+
*/
|
|
105
|
+
export declare const VERDICT_CACHE_VERSION = 1;
|
|
106
|
+
export declare const VERDICT_CACHE_FILE = "verdicts.json";
|
|
107
|
+
export declare const VERDICT_CACHE_MAX_ENTRIES = 5000;
|
|
108
|
+
/** AniList 判定只保留 7 天:它来自外部 API,短周期复核更稳妥 */
|
|
109
|
+
export declare const ANILIST_VERDICT_TTL_MS: number;
|
|
110
|
+
/** Bangumi 逐集档期判定来自本地化缓存,跟它一样保留 30 天 */
|
|
111
|
+
export declare const EPISODE_VERDICT_TTL_MS: number;
|
|
98
112
|
/** 取逐集档期时的单页集数上限;超过这个数的长期番不靠集数判断完结 */
|
|
99
113
|
export declare const EPISODE_QUERY_LIMIT = 100;
|
|
100
114
|
/**
|
package/lib/constants.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LOG_MESSAGES = exports.RESPONSES = exports.MESSAGES = exports.IMAGE_THEME = exports.IMAGE_LAYOUT = exports.DEFAULT_FONT_FAMILY = exports.BUILD_CACHE_MAX_ENTRIES = exports.BUILD_CACHE_TTL_MS = exports.DAILY_OFFSET_MAX = exports.DAILY_OFFSET_MIN = exports.MAX_SUPERSAMPLE = exports.DEFAULT_SUPERSAMPLE = exports.DEFAULT_JPEG_QUALITY = exports.BOLD_FONT_CANDIDATES = exports.FONT_CANDIDATES = exports.COVER_MEMORY_MAX_BYTES = exports.COVER_CACHE_MAX_FILES = exports.COVER_CACHE_TTL_MS = exports.COVER_CACHE_DIR = exports.COVER_MAX_BYTES = exports.COVER_TIMEOUT_MS = exports.COVER_CONCURRENCY = exports.LOCALIZE_TIMEOUT_SECONDS = exports.LOCALIZE_CONCURRENCY = exports.EPISODE_QUERY_LIMIT = exports.LOCALIZE_CACHE_MAX_ENTRIES = exports.LOCALIZE_CACHE_VERSION = exports.MIN_SCORE_VOTES = exports.DEFAULT_SCORE_TTL_HOURS = exports.LOCALIZE_CACHE_FILE = exports.LATE_NIGHT_END_HOUR = exports.IMAGE_LIST_TWO_COLUMN_AT = exports.IMAGE_MAX_CARDS = exports.DIGEST_LABELS = exports.PLUGIN_HOMEPAGE = exports.PLUGIN_VERSION = exports.DATA_DIR = exports.BANGUMI_DATA_FILE = exports.BANGUMI_DATA_FALLBACK_URLS = exports.ANILIST_COOLDOWN_MS = exports.ANILIST_MIN_INTERVAL_MS = exports.ANILIST_BATCH_SIZE = exports.ANILIST_API_URL = exports.BANGUMI_API_HOST = exports.PLUGIN_NAME = void 0;
|
|
3
|
+
exports.LOG_MESSAGES = exports.RESPONSES = exports.MESSAGES = exports.IMAGE_THEME = exports.IMAGE_LAYOUT = exports.DEFAULT_FONT_FAMILY = exports.BUILD_CACHE_MAX_ENTRIES = exports.BUILD_CACHE_TTL_MS = exports.DAILY_OFFSET_MAX = exports.DAILY_OFFSET_MIN = exports.MAX_SUPERSAMPLE = exports.DEFAULT_SUPERSAMPLE = exports.DEFAULT_JPEG_QUALITY = exports.BOLD_FONT_CANDIDATES = exports.FONT_CANDIDATES = exports.COVER_MEMORY_MAX_BYTES = exports.COVER_CACHE_MAX_FILES = exports.COVER_CACHE_TTL_MS = exports.COVER_CACHE_DIR = exports.COVER_MAX_BYTES = exports.COVER_TIMEOUT_MS = exports.COVER_CONCURRENCY = exports.LOCALIZE_TIMEOUT_SECONDS = exports.LOCALIZE_CONCURRENCY = exports.EPISODE_QUERY_LIMIT = exports.EPISODE_VERDICT_TTL_MS = exports.ANILIST_VERDICT_TTL_MS = exports.VERDICT_CACHE_MAX_ENTRIES = exports.VERDICT_CACHE_FILE = exports.VERDICT_CACHE_VERSION = exports.LOCALIZE_CACHE_MAX_ENTRIES = exports.LOCALIZE_CACHE_VERSION = exports.MIN_SCORE_VOTES = exports.DEFAULT_SCORE_TTL_HOURS = exports.LOCALIZE_CACHE_FILE = exports.LATE_NIGHT_END_HOUR = exports.IMAGE_LIST_TWO_COLUMN_AT = exports.IMAGE_MAX_CARDS = exports.DIGEST_LABELS = exports.PLUGIN_HOMEPAGE = exports.PLUGIN_VERSION = exports.DATA_DIR = exports.BANGUMI_DATA_FILE = exports.BANGUMI_DATA_FALLBACK_URLS = exports.ANILIST_COOLDOWN_MS = exports.ANILIST_MIN_INTERVAL_MS = exports.ANILIST_BATCH_SIZE = exports.ANILIST_API_URL = exports.BANGUMI_API_HOST = exports.PLUGIN_NAME = void 0;
|
|
4
4
|
exports.buildUserAgent = buildUserAgent;
|
|
5
5
|
exports.getMessages = getMessages;
|
|
6
6
|
/** 插件名,同时作为日志前缀与 User-Agent 主体 */
|
|
@@ -119,6 +119,20 @@ exports.MIN_SCORE_VOTES = 10;
|
|
|
119
119
|
exports.LOCALIZE_CACHE_VERSION = 6;
|
|
120
120
|
/** 本地化缓存最大条目数,超出后按写入时间淘汰最旧的一半 */
|
|
121
121
|
exports.LOCALIZE_CACHE_MAX_ENTRIES = 5000;
|
|
122
|
+
/**
|
|
123
|
+
* 「不采信 / 已剔除」判定缓存的版本与有效期。
|
|
124
|
+
*
|
|
125
|
+
* 这些判定的输入都是慢变量(bangumi-data 的 `aniList` ID、Bangumi 逐集档期),
|
|
126
|
+
* 但上游偶尔会修数据,因此不设永久缓存:AniList 判定 7 天后复核,Bangumi 档期判定
|
|
127
|
+
* 30 天后复核(与本地化缓存一致)。
|
|
128
|
+
*/
|
|
129
|
+
exports.VERDICT_CACHE_VERSION = 1;
|
|
130
|
+
exports.VERDICT_CACHE_FILE = 'verdicts.json';
|
|
131
|
+
exports.VERDICT_CACHE_MAX_ENTRIES = 5000;
|
|
132
|
+
/** AniList 判定只保留 7 天:它来自外部 API,短周期复核更稳妥 */
|
|
133
|
+
exports.ANILIST_VERDICT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
134
|
+
/** Bangumi 逐集档期判定来自本地化缓存,跟它一样保留 30 天 */
|
|
135
|
+
exports.EPISODE_VERDICT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
122
136
|
/** 取逐集档期时的单页集数上限;超过这个数的长期番不靠集数判断完结 */
|
|
123
137
|
exports.EPISODE_QUERY_LIMIT = 100;
|
|
124
138
|
/**
|
package/lib/index.js
CHANGED
|
@@ -107,12 +107,13 @@ function apply(ctx, rawConfig) {
|
|
|
107
107
|
// 立刻开始载入并与插件启动并行:指令与推送在构建前会 await 它,不会出现「索引还没好」的空窗
|
|
108
108
|
const indexReady = index.ensure();
|
|
109
109
|
const localizeReady = digest.localizer.load();
|
|
110
|
+
const verdictReady = digest.verdicts.load();
|
|
110
111
|
const airingReady = airing.ensure();
|
|
111
112
|
// 免去「未处理的 Promise 拒绝」告警,真实错误在 ready 与构建路径里各自上报
|
|
112
|
-
Promise.all([indexReady, localizeReady, airingReady]).catch(() => undefined);
|
|
113
|
+
Promise.all([indexReady, localizeReady, verdictReady, airingReady]).catch(() => undefined);
|
|
113
114
|
const dispatcher = new dispatch_1.Dispatcher(ctx, config, logger, (payload) => renderer.render((0, image_1.toRenderPayload)(payload, (0, constants_1.getMessages)(config.locale))));
|
|
114
115
|
const scheduler = new scheduler_1.Scheduler(ctx, config, logger, digest, dispatcher, guard, async () => {
|
|
115
|
-
await digest.localizer.save();
|
|
116
|
+
await Promise.all([digest.localizer.save(), digest.verdicts.save()]);
|
|
116
117
|
// 顺手把已完结的订阅清掉;`pruneFinishedSubscriptions` 自己按天去重
|
|
117
118
|
await digest.pruneFinishedSubscriptions().catch((error) => {
|
|
118
119
|
logger.debug('[lifecycle] 清理已完结订阅失败: %s', (0, utils_1.errorMessage)(error));
|
|
@@ -123,7 +124,7 @@ function apply(ctx, rawConfig) {
|
|
|
123
124
|
ctx.on('ready', async () => {
|
|
124
125
|
try {
|
|
125
126
|
renderer.init();
|
|
126
|
-
await Promise.all([localizeReady, indexReady]);
|
|
127
|
+
await Promise.all([localizeReady, indexReady, verdictReady]);
|
|
127
128
|
const anyPushEnabled = config.push.dailyEnabled
|
|
128
129
|
|| config.push.nightEnabled
|
|
129
130
|
|| config.push.weeklyEnabled
|
|
@@ -142,9 +143,14 @@ function apply(ctx, rawConfig) {
|
|
|
142
143
|
});
|
|
143
144
|
ctx.on('dispose', async () => {
|
|
144
145
|
scheduler.stop();
|
|
145
|
-
await
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
await Promise.all([
|
|
147
|
+
digest.localizer.save().catch((error) => {
|
|
148
|
+
logger.debug('[lifecycle] 保存本地化缓存失败: %s', (0, utils_1.errorMessage)(error));
|
|
149
|
+
}),
|
|
150
|
+
digest.verdicts.save().catch((error) => {
|
|
151
|
+
logger.debug('[lifecycle] 保存判定缓存失败: %s', (0, utils_1.errorMessage)(error));
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
148
154
|
});
|
|
149
155
|
}
|
|
150
156
|
function registerCommands(ctx, deps) {
|
package/lib/services/digest.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Config } from '../config';
|
|
|
3
3
|
import type { AnimeChannel, AnimeSubscription, Digest, DigestItem, DigestKind } from '../types';
|
|
4
4
|
import type { AiringStatusService } from './airing';
|
|
5
5
|
import { Localizer } from './localizer';
|
|
6
|
+
import { VerdictCache } from './verdicts';
|
|
6
7
|
import type { HttpClient } from './network';
|
|
7
8
|
import { BangumiDataIndex } from '../data/bangumi-data';
|
|
8
9
|
/**
|
|
@@ -16,6 +17,8 @@ export declare class DigestService {
|
|
|
16
17
|
/** AniList 播出状态查询;未注入时退化为「按 Bangumi 逐集档期判断」 */
|
|
17
18
|
private airing?;
|
|
18
19
|
readonly localizer: Localizer;
|
|
20
|
+
/** 「不采信 / 已剔除」的持久化判定,避免每次构建重复请求与判断 */
|
|
21
|
+
readonly verdicts: VerdictCache;
|
|
19
22
|
/** 已构建结果的短缓存,见 `shared()` */
|
|
20
23
|
private readonly recentBuilds;
|
|
21
24
|
/** 同一份内容正被构建时,后来的调用直接等它,不重复构建 */
|
|
@@ -76,6 +79,11 @@ export declare class DigestService {
|
|
|
76
79
|
* 3. `dropFinishedByEpisodeDates`(Bangumi 逐集档期的末話日期,需 `/v0/subjects` + `/v0/episodes`)
|
|
77
80
|
* 顺序很重要:1、2 不新增请求,先把大部分候选挡掉,3 于是只为**幸存者**付出代价。
|
|
78
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* 闸门 0:上次构建已经得出「这部已完结 / 这条记录不可信」的结论,直接复用,不再请求
|
|
84
|
+
* AniList,也不再为它取 Bangumi 逐集档期。只影响按播出时刻列作品的表,本季表不走这里。
|
|
85
|
+
*/
|
|
86
|
+
private dropCachedVerdicts;
|
|
79
87
|
/**
|
|
80
88
|
* 闸门 1:只看本地索引就能判定「已播完」,**不产生任何请求**。依据是 bangumi-data 的 `end`
|
|
81
89
|
* 与「近期开播且后续仍有排期」(当季条目 `end` 几乎为空,靠这条兜住)。
|
|
@@ -97,6 +105,8 @@ export declare class DigestService {
|
|
|
97
105
|
* @param fromMs 以哪个时刻为准:出表闸门用该场的播出时刻,订阅清理用当前时刻。
|
|
98
106
|
*/
|
|
99
107
|
private aniListFinished;
|
|
108
|
+
/** 已确认完结时,下一场从哪个时刻之后可以直接剔除(用于判定缓存) */
|
|
109
|
+
private aniListDropAfterMs;
|
|
100
110
|
/**
|
|
101
111
|
* 这条 AniList 记录与 `base` 是不是同一季。**只用于「是否已播完」,不用于排期**(`status`
|
|
102
112
|
* 一旦采信就会把作品从各表里剔除)。取逐話时刻时故意不做这个校验,见 `applyAiringSchedule`。
|
package/lib/services/digest.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DigestService = void 0;
|
|
4
4
|
const constants_1 = require("../constants");
|
|
5
5
|
const localizer_1 = require("./localizer");
|
|
6
|
+
const verdicts_1 = require("./verdicts");
|
|
6
7
|
const time_1 = require("../utils/time");
|
|
7
8
|
const utils_1 = require("../utils");
|
|
8
9
|
/** 「本周表」向后覆盖的天数 */
|
|
@@ -91,6 +92,8 @@ class DigestService {
|
|
|
91
92
|
index;
|
|
92
93
|
airing;
|
|
93
94
|
localizer;
|
|
95
|
+
/** 「不采信 / 已剔除」的持久化判定,避免每次构建重复请求与判断 */
|
|
96
|
+
verdicts;
|
|
94
97
|
/** 已构建结果的短缓存,见 `shared()` */
|
|
95
98
|
recentBuilds = new Map();
|
|
96
99
|
/** 同一份内容正被构建时,后来的调用直接等它,不重复构建 */
|
|
@@ -103,6 +106,7 @@ class DigestService {
|
|
|
103
106
|
this.index = index;
|
|
104
107
|
this.airing = airing;
|
|
105
108
|
this.localizer = new localizer_1.Localizer(ctx, config, logger, http);
|
|
109
|
+
this.verdicts = new verdicts_1.VerdictCache(ctx, logger);
|
|
106
110
|
}
|
|
107
111
|
get timeZone() {
|
|
108
112
|
return this.config.timeZone;
|
|
@@ -112,7 +116,7 @@ class DigestService {
|
|
|
112
116
|
}
|
|
113
117
|
/** 等索引与本地化缓存就绪 */
|
|
114
118
|
async prepare() {
|
|
115
|
-
await Promise.all([this.index.ensure(), this.localizer.ensure()]);
|
|
119
|
+
await Promise.all([this.index.ensure(), this.localizer.ensure(), this.verdicts.ensure()]);
|
|
116
120
|
}
|
|
117
121
|
// #region 排期推算
|
|
118
122
|
/**
|
|
@@ -366,6 +370,22 @@ class DigestService {
|
|
|
366
370
|
* 3. `dropFinishedByEpisodeDates`(Bangumi 逐集档期的末話日期,需 `/v0/subjects` + `/v0/episodes`)
|
|
367
371
|
* 顺序很重要:1、2 不新增请求,先把大部分候选挡掉,3 于是只为**幸存者**付出代价。
|
|
368
372
|
*/
|
|
373
|
+
/**
|
|
374
|
+
* 闸门 0:上次构建已经得出「这部已完结 / 这条记录不可信」的结论,直接复用,不再请求
|
|
375
|
+
* AniList,也不再为它取 Bangumi 逐集档期。只影响按播出时刻列作品的表,本季表不走这里。
|
|
376
|
+
*/
|
|
377
|
+
dropCachedVerdicts(candidates) {
|
|
378
|
+
let dropped = 0;
|
|
379
|
+
const kept = candidates.filter((candidate) => {
|
|
380
|
+
if (!this.verdicts.shouldDrop(candidate.entry, candidate.atMs))
|
|
381
|
+
return true;
|
|
382
|
+
dropped++;
|
|
383
|
+
return false;
|
|
384
|
+
});
|
|
385
|
+
if (dropped > 0)
|
|
386
|
+
(0, utils_1.debugLog)('[digest] 按判定缓存剔除 %d 场', dropped);
|
|
387
|
+
return kept;
|
|
388
|
+
}
|
|
369
389
|
/**
|
|
370
390
|
* 闸门 1:只看本地索引就能判定「已播完」,**不产生任何请求**。依据是 bangumi-data 的 `end`
|
|
371
391
|
* 与「近期开播且后续仍有排期」(当季条目 `end` 几乎为空,靠这条兜住)。
|
|
@@ -393,11 +413,24 @@ class DigestService {
|
|
|
393
413
|
let dropped = 0;
|
|
394
414
|
const kept = candidates.filter((candidate) => {
|
|
395
415
|
const state = candidate.entry.aniListId === null ? undefined : airing.get(candidate.entry.aniListId);
|
|
396
|
-
//
|
|
416
|
+
// 记录指向别季:以后不必再请求这条 ID,直接跳过(由闸门 3 用 Bangumi 档期兜底)。
|
|
417
|
+
// 顺手从本次状态表里删掉,免得闸门 3 再校验一次、也免得 `applyAiringSchedule`
|
|
418
|
+
// 误用别季的逐话时刻。
|
|
419
|
+
if (state && !this.isSameAniListSeason(state, candidate.entry.broadcastMs)) {
|
|
420
|
+
this.verdicts.record(candidate.entry, 'anilist-mismatch');
|
|
421
|
+
if (candidate.entry.aniListId !== null)
|
|
422
|
+
airing.delete(candidate.entry.aniListId);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
// 以「这一场的播出时刻」为准。无法判定(终映日异常)时一律保留:
|
|
397
426
|
// 表格里多留一部只是不好看,误删一部会让用户以为没更新。
|
|
398
|
-
const finished = this.aniListFinished(state, candidate.entry.broadcastMs, now, candidate.atMs);
|
|
427
|
+
const finished = this.aniListFinished(state, candidate.entry.broadcastMs, now, candidate.atMs, true);
|
|
399
428
|
if (finished !== true)
|
|
400
429
|
return true;
|
|
430
|
+
this.verdicts.record(candidate.entry, 'anilist-finished', {
|
|
431
|
+
// 下一场只要晚于末集时刻就必定也已播完,不必再请求 AniList
|
|
432
|
+
dropAfterMs: this.aniListDropAfterMs(state, candidate.atMs),
|
|
433
|
+
});
|
|
401
434
|
(0, utils_1.debugLog)('[digest] 剔除 %s:AniList 状态 %s,已播 %s / %s 集,终映 %s', candidate.entry.title, state?.status ?? '?', state?.airedEpisodes ?? '?', state?.episodes ?? '?', state?.endDate ?? '?');
|
|
402
435
|
dropped++;
|
|
403
436
|
return false;
|
|
@@ -415,8 +448,8 @@ class DigestService {
|
|
|
415
448
|
*
|
|
416
449
|
* @param fromMs 以哪个时刻为准:出表闸门用该场的播出时刻,订阅清理用当前时刻。
|
|
417
450
|
*/
|
|
418
|
-
aniListFinished(state, base, now, fromMs) {
|
|
419
|
-
if (!state || !this.isSameAniListSeason(state, base))
|
|
451
|
+
aniListFinished(state, base, now, fromMs, seasonChecked = false) {
|
|
452
|
+
if (!state || (!seasonChecked && !this.isSameAniListSeason(state, base)))
|
|
420
453
|
return null;
|
|
421
454
|
// 还在连载或还没开播,一律不算完结
|
|
422
455
|
if (state.status === 'RELEASING' || state.status === 'NOT_YET_RELEASED')
|
|
@@ -440,6 +473,18 @@ class DigestService {
|
|
|
440
473
|
}
|
|
441
474
|
return null;
|
|
442
475
|
}
|
|
476
|
+
/** 已确认完结时,下一场从哪个时刻之后可以直接剔除(用于判定缓存) */
|
|
477
|
+
aniListDropAfterMs(state, fallback) {
|
|
478
|
+
if (state?.lastAiredMs !== null && state?.lastAiredMs !== undefined)
|
|
479
|
+
return state.lastAiredMs;
|
|
480
|
+
if (state?.endDate) {
|
|
481
|
+
const endMs = Date.parse(`${state.endDate}T00:00:00Z`);
|
|
482
|
+
if (Number.isFinite(endMs))
|
|
483
|
+
return endMs;
|
|
484
|
+
}
|
|
485
|
+
// 字段不全时退回本次场次:至少之后的场次不会再重复请求
|
|
486
|
+
return fallback;
|
|
487
|
+
}
|
|
443
488
|
/**
|
|
444
489
|
* 这条 AniList 记录与 `base` 是不是同一季。**只用于「是否已播完」,不用于排期**(`status`
|
|
445
490
|
* 一旦采信就会把作品从各表里剔除)。取逐話时刻时故意不做这个校验,见 `applyAiringSchedule`。
|
|
@@ -496,14 +541,26 @@ class DigestService {
|
|
|
496
541
|
if (missing.size > pending.length) {
|
|
497
542
|
(0, utils_1.debugLog)('[digest] 还有 %d 部缺 AniList ID 的条目未处理,留待后续构建', missing.size - pending.length);
|
|
498
543
|
}
|
|
499
|
-
const ids =
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
544
|
+
const ids = new Set();
|
|
545
|
+
let cachedSkipped = 0;
|
|
546
|
+
for (const candidate of candidates) {
|
|
547
|
+
const { entry } = candidate;
|
|
548
|
+
if (entry.aniListId === null)
|
|
549
|
+
continue;
|
|
550
|
+
// 上次已确认这条 AniList 记录指向别季:不再请求,闸门 3 会改用 Bangumi 逐集档期
|
|
551
|
+
if (this.verdicts.shouldSkipAniList(entry)) {
|
|
552
|
+
cachedSkipped++;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
ids.add(entry.aniListId);
|
|
556
|
+
}
|
|
557
|
+
if (cachedSkipped > 0)
|
|
558
|
+
(0, utils_1.debugLog)('[digest] 按判定缓存跳过 %d 个 AniList 状态请求', cachedSkipped);
|
|
559
|
+
if (!ids.size)
|
|
503
560
|
return new Map();
|
|
504
561
|
await this.airing.ensure();
|
|
505
562
|
try {
|
|
506
|
-
return await this.airing.resolve(ids, this.config.localize.airingTtlHours / 24, now);
|
|
563
|
+
return await this.airing.resolve([...ids], this.config.localize.airingTtlHours / 24, now);
|
|
507
564
|
}
|
|
508
565
|
catch (error) {
|
|
509
566
|
(0, utils_1.debugLog)('[digest] AniList 状态查询失败,本次按逐集档期判定: %s', (0, utils_1.errorMessage)(error));
|
|
@@ -519,11 +576,17 @@ class DigestService {
|
|
|
519
576
|
return candidates;
|
|
520
577
|
let dropped = 0;
|
|
521
578
|
let skipped = 0;
|
|
579
|
+
let cached = 0;
|
|
522
580
|
// 这一段的明细一部一行,用有上限的记录器兜住
|
|
523
581
|
const detail = boundedLog();
|
|
524
582
|
const kept = candidates.filter((candidate) => {
|
|
525
583
|
const id = candidate.entry.bangumiId;
|
|
526
584
|
const base = candidate.entry.broadcastMs;
|
|
585
|
+
// 上次已确认逐集档期与开播日期对不上:不再判断,直接按本地排期保留
|
|
586
|
+
if (this.verdicts.shouldSkipEpisodeDates(candidate.entry)) {
|
|
587
|
+
cached++;
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
527
590
|
// AniList 已经对这部作品表态(同一季)→ 由它说了算,逐集档期不得推翻
|
|
528
591
|
const state = candidate.entry.aniListId === null ? undefined : airing?.get(candidate.entry.aniListId);
|
|
529
592
|
if (state && this.isSameAniListSeason(state, base)) {
|
|
@@ -540,6 +603,7 @@ class DigestService {
|
|
|
540
603
|
const apiFirst = entry?.firstAirMs ?? null;
|
|
541
604
|
if (apiFirst !== null && Math.abs(base - apiFirst) > STALE_SEASON_GAP_DAYS * 86400000) {
|
|
542
605
|
detail.log('[digest] 剔除 %s: 开播日期 %s 与逐集档期首集 %s 相差 %d 天,上游数据不一致', candidate.entry.title, new Date(base).toISOString().slice(0, 10), new Date(apiFirst).toISOString().slice(0, 10), Math.round((base - apiFirst) / 86400000));
|
|
606
|
+
this.verdicts.record(candidate.entry, 'episode-mismatch');
|
|
543
607
|
dropped++;
|
|
544
608
|
return false;
|
|
545
609
|
}
|
|
@@ -552,11 +616,13 @@ class DigestService {
|
|
|
552
616
|
const offsetPeriods = (stated - expectedLast) / period;
|
|
553
617
|
if (Math.abs(offsetPeriods) > FINAL_EPISODE_TOLERANCE_PERIODS) {
|
|
554
618
|
detail.log('[digest] 不采信 %s 的逐集档期: 第 %d 集推算 %s,档期给出 %s(差 %s 个周期)', candidate.entry.title, total, new Date(expectedLast).toISOString().slice(0, 10), lastLabel, offsetPeriods.toFixed(1));
|
|
619
|
+
this.verdicts.markEpisodeUntrusted(candidate.entry);
|
|
555
620
|
return true;
|
|
556
621
|
}
|
|
557
622
|
const spanDays = (stated - base) / 86400000;
|
|
558
623
|
if (spanDays > (total - 1) * periodDays + FINAL_EPISODE_SPAN_SLACK_DAYS) {
|
|
559
624
|
detail.log('[digest] 不采信 %s 的逐集档期: 跨度 %d 天超出 %d 集的最小跨度', candidate.entry.title, Math.round(spanDays), total);
|
|
625
|
+
this.verdicts.markEpisodeUntrusted(candidate.entry);
|
|
560
626
|
return true;
|
|
561
627
|
}
|
|
562
628
|
// 判据:**这一场的 JST 日期已越过末集一天以上**。
|
|
@@ -567,6 +633,7 @@ class DigestService {
|
|
|
567
633
|
const lastDay = Math.floor(stated / 86400000);
|
|
568
634
|
if (candidateDay - lastDay <= FINAL_EPISODE_DAY_GRACE)
|
|
569
635
|
return true;
|
|
636
|
+
this.verdicts.record(candidate.entry, 'episode-finished', { dropAfterDay: lastDay });
|
|
570
637
|
detail.log('[digest] 剔除 %s: 末集 %s,本场已在其后 %d 天', candidate.entry.title, lastLabel, candidateDay - lastDay);
|
|
571
638
|
dropped++;
|
|
572
639
|
return false;
|
|
@@ -578,6 +645,8 @@ class DigestService {
|
|
|
578
645
|
(0, utils_1.debugLog)('[digest] 剔除已播完的作品 %d 部(Bangumi 逐集档期)', dropped);
|
|
579
646
|
if (skipped > 0)
|
|
580
647
|
(0, utils_1.debugLog)('[digest] %d 部由 AniList 判定,逐集档期未参与', skipped);
|
|
648
|
+
if (cached > 0)
|
|
649
|
+
(0, utils_1.debugLog)('[digest] %d 部按判定缓存跳过逐集档期判断', cached);
|
|
581
650
|
return kept;
|
|
582
651
|
}
|
|
583
652
|
/**
|
|
@@ -611,6 +680,9 @@ class DigestService {
|
|
|
611
680
|
*/
|
|
612
681
|
async subjectsForEpisodeDates(candidates, airing) {
|
|
613
682
|
const wanted = candidates.filter((candidate) => {
|
|
683
|
+
// 上次已判定逐集档期不可信 → 闸门 3 不参与,详情不必为此取
|
|
684
|
+
if (this.verdicts.shouldSkipEpisodeDates(candidate.entry))
|
|
685
|
+
return false;
|
|
614
686
|
const state = candidate.entry.aniListId === null ? undefined : airing?.get(candidate.entry.aniListId);
|
|
615
687
|
// AniList 已表态(同一季)→ 闸门 3 不参与,详情不必取
|
|
616
688
|
return !(state && this.isSameAniListSeason(state, candidate.entry.broadcastMs));
|
|
@@ -777,12 +849,13 @@ class DigestService {
|
|
|
777
849
|
const window = this.windowMeta(kind, now, options.fromMs);
|
|
778
850
|
const predicted = this.airingsIn(options);
|
|
779
851
|
(0, utils_1.debugLog)('[digest] %s: 推算 %d 场', kind, predicted.length);
|
|
780
|
-
// ── 闸门 1 + 2:都不需要新增请求,先把大部分候选挡掉 ──
|
|
852
|
+
// ── 闸门 0 + 1 + 2:都不需要新增请求,先把大部分候选挡掉 ──
|
|
781
853
|
//
|
|
782
|
-
// bangumi-data 的 `end`
|
|
783
|
-
//
|
|
854
|
+
// 判定缓存复用上次的「已完结 / 记录不可信」结论;bangumi-data 的 `end` 与
|
|
855
|
+
// 「近期仍有排期」是纯本地判定;AniList 状态本来就要取,因此下面只为幸存者请求 Bangumi 详情。
|
|
856
|
+
const cached = this.dropCachedVerdicts(predicted);
|
|
784
857
|
const following = this.followingKeys(now);
|
|
785
|
-
const locallyKept = this.dropLocallyFinished(
|
|
858
|
+
const locallyKept = this.dropLocallyFinished(cached, options.fromMs, following);
|
|
786
859
|
const airingStates = await this.airingStatesOf(locallyKept, now);
|
|
787
860
|
const survived = this.dropFinishedByAniList(locallyKept, options.fromMs, airingStates);
|
|
788
861
|
// ── 闸门 3:只为**真正要判的**作品取 Bangumi 详情 ──
|
|
@@ -1054,9 +1127,10 @@ class DigestService {
|
|
|
1054
1127
|
activeOnly: false,
|
|
1055
1128
|
onePerTitle: true,
|
|
1056
1129
|
}).filter((candidate) => wanted.has(defaultTidOf(candidate.entry) ?? candidate.entry.key));
|
|
1057
|
-
// 与 buildAirings
|
|
1130
|
+
// 与 buildAirings 同一套闸门:先复用判定缓存与本地依据,再 AniList、最后才为幸存者取 Bangumi 逐集档期
|
|
1131
|
+
const cached = this.dropCachedVerdicts(candidates);
|
|
1058
1132
|
const following = this.followingKeys(startMs);
|
|
1059
|
-
const locallyKept = this.dropLocallyFinished(
|
|
1133
|
+
const locallyKept = this.dropLocallyFinished(cached, startMs, following);
|
|
1060
1134
|
const airingStates = await this.airingStatesOf(locallyKept, now);
|
|
1061
1135
|
const survived = this.dropFinishedByAniList(locallyKept, startMs, airingStates);
|
|
1062
1136
|
// 与 `buildAirings` 同一套做法:只为闸门 3 真正要判的作品取详情
|
|
@@ -1215,20 +1289,30 @@ class DigestService {
|
|
|
1215
1289
|
}
|
|
1216
1290
|
if (!entries.size)
|
|
1217
1291
|
return [];
|
|
1218
|
-
|
|
1219
|
-
|
|
1292
|
+
// 判定缓存已确认完结的作品不再请求 AniList;其余仍按状态与本地 end 判断
|
|
1293
|
+
const graceNow = now - 86400000;
|
|
1294
|
+
const pending = [...entries.values()].filter((entry) => !this.verdicts.shouldDrop(entry, graceNow));
|
|
1295
|
+
const states = await this.resolveAiringStates(pending, now);
|
|
1296
|
+
return [...entries]
|
|
1297
|
+
.filter(([, entry]) => this.verdicts.shouldDrop(entry, graceNow) || this.isEntryFinished(entry, states, now))
|
|
1298
|
+
.map(([tid]) => tid);
|
|
1220
1299
|
}
|
|
1221
1300
|
/** 批量取这批条目对应的 AniList 状态;取不到时返回空表(调用方会退回本地依据) */
|
|
1222
1301
|
async resolveAiringStates(entries, now) {
|
|
1223
1302
|
if (!this.airing)
|
|
1224
1303
|
return new Map();
|
|
1225
|
-
const ids =
|
|
1226
|
-
|
|
1227
|
-
.
|
|
1228
|
-
|
|
1304
|
+
const ids = new Set();
|
|
1305
|
+
for (const entry of entries) {
|
|
1306
|
+
if (entry.aniListId === null)
|
|
1307
|
+
continue;
|
|
1308
|
+
if (this.verdicts.shouldSkipAniList(entry))
|
|
1309
|
+
continue;
|
|
1310
|
+
ids.add(entry.aniListId);
|
|
1311
|
+
}
|
|
1312
|
+
if (!ids.size)
|
|
1229
1313
|
return new Map();
|
|
1230
1314
|
try {
|
|
1231
|
-
return await this.airing.resolve(ids, this.config.localize.airingTtlHours / 24, now);
|
|
1315
|
+
return await this.airing.resolve([...ids], this.config.localize.airingTtlHours / 24, now);
|
|
1232
1316
|
}
|
|
1233
1317
|
catch (error) {
|
|
1234
1318
|
(0, utils_1.debugLog)('[digest] 取 AniList 状态失败,改用本地依据: %s', (0, utils_1.errorMessage)(error));
|
|
@@ -1300,6 +1384,9 @@ class DigestService {
|
|
|
1300
1384
|
* 而那一话要到晚上才播,当天就停用订阅会让用户以为漏了最后一集。
|
|
1301
1385
|
*/
|
|
1302
1386
|
isEntryFinished(entry, states, now) {
|
|
1387
|
+
// 判定缓存已经把「已完结」定论过:不必再请求 AniList;沿用现有的一天宽限
|
|
1388
|
+
if (this.verdicts.shouldDrop(entry, now - 86400000))
|
|
1389
|
+
return true;
|
|
1303
1390
|
const state = entry.aniListId === null ? undefined : states.get(entry.aniListId);
|
|
1304
1391
|
const finished = this.aniListFinished(state, entry.broadcastMs, now, now - 86400000);
|
|
1305
1392
|
if (finished !== null)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Context, Logger } from 'koishi';
|
|
2
|
+
import type { IndexEntry } from '../data/bangumi-data';
|
|
3
|
+
/**
|
|
4
|
+
* 判定缓存记录的原因。只保存**下次构建仍会得出同一结论**的判定:
|
|
5
|
+
*
|
|
6
|
+
* - `anilist-mismatch`:AniList 记录指向别季,不应再拿它的状态做完结判断;
|
|
7
|
+
* - `anilist-finished`:AniList 已确认完结,且给出了最后一集的时刻;
|
|
8
|
+
* - `episode-mismatch`:bangumi-data 的开播日期与 Bangumi 逐集档期首集相差数年,
|
|
9
|
+
* 这个条目已不可信;
|
|
10
|
+
* - `episode-untrusted`:Bangumi 逐集档期的末集与开播日期推算差得太远,不能拿来完结判断,
|
|
11
|
+
* 但作品本身仍应保留;
|
|
12
|
+
* - `episode-finished`:Bangumi 逐集档期确认末集已过去。
|
|
13
|
+
*
|
|
14
|
+
* 与「本次窗口」有关的判定(例如 AniList 排期没覆盖本周)不写入缓存,否则会挡住后续
|
|
15
|
+
* 真正的更新。
|
|
16
|
+
*/
|
|
17
|
+
export type VerdictReason = 'anilist-mismatch' | 'anilist-finished' | 'episode-mismatch' | 'episode-untrusted' | 'episode-finished';
|
|
18
|
+
export interface VerdictRecord {
|
|
19
|
+
key: string;
|
|
20
|
+
title: string;
|
|
21
|
+
reason: VerdictReason;
|
|
22
|
+
/** 判定作出时条目的 AniList ID;AniList ID 变了就作废 */
|
|
23
|
+
aniListId: number | null;
|
|
24
|
+
/** 精确时刻判定:`atMs` 晚于它才剔除(用于 AniList 的末集时刻) */
|
|
25
|
+
dropAfterMs: number | null;
|
|
26
|
+
/** 按日判定:场次的 JST 日期晚于它才剔除(用于 Bangumi 的末集日期) */
|
|
27
|
+
dropAfterDay: number | null;
|
|
28
|
+
at: number;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 「不采信 / 已剔除」判定的本地持久化缓存。
|
|
33
|
+
*
|
|
34
|
+
* 这些判定的输入(bangumi-data 的 `aniList` ID、逐集档期)很少变化,但原实现每次构建
|
|
35
|
+
* 都会重新查 AniList、重新算一遍,然后再次得出「这条记录不是这一季」或「这部已经播完」。
|
|
36
|
+
* 周表一次构建要为此多发几十个请求、多打几十行日志。
|
|
37
|
+
*
|
|
38
|
+
* 缓存只影响 daily / night / weekly / sub 的完结闸门,**不影响本季表**:季表本来就要
|
|
39
|
+
* 展示整季作品,不能因为「已完结」把它们藏起来。
|
|
40
|
+
*/
|
|
41
|
+
export declare class VerdictCache {
|
|
42
|
+
private ctx;
|
|
43
|
+
private logger;
|
|
44
|
+
private readonly records;
|
|
45
|
+
private readonly cachePath;
|
|
46
|
+
private loaded;
|
|
47
|
+
private loadPromise;
|
|
48
|
+
private dirty;
|
|
49
|
+
private flushTimer;
|
|
50
|
+
private flushing;
|
|
51
|
+
private writeFailed;
|
|
52
|
+
constructor(ctx: Context, logger: Logger);
|
|
53
|
+
/** 确保缓存已从磁盘读取;重复调用只读一次 */
|
|
54
|
+
ensure(): Promise<void>;
|
|
55
|
+
load(): Promise<void>;
|
|
56
|
+
/** 这条场次是否已被缓存判定为「应当剔除」 */
|
|
57
|
+
shouldDrop(entry: IndexEntry, atMs: number): boolean;
|
|
58
|
+
/** 是否应跳过这条条目的 AniList 状态请求(记录指向别季) */
|
|
59
|
+
shouldSkipAniList(entry: IndexEntry): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* 是否应跳过这条条目的逐集档期完结判断。
|
|
62
|
+
*
|
|
63
|
+
* 与 AniList 判定分开存:一部作品可能同时「AniList 指向别季」和「Bangumi 逐集档期不可信」,
|
|
64
|
+
* 两条判定都要保留,不能互相覆盖,所以逐集档期用独立的 `#episodes` 键。
|
|
65
|
+
*/
|
|
66
|
+
shouldSkipEpisodeDates(entry: IndexEntry): boolean;
|
|
67
|
+
/** 记录一次判定;同一部作品后出现的「剔除」记录可以覆盖较轻的「不采信」 */
|
|
68
|
+
record(entry: IndexEntry, reason: VerdictReason, patch?: {
|
|
69
|
+
dropAfterMs?: number | null;
|
|
70
|
+
dropAfterDay?: number | null;
|
|
71
|
+
}): void;
|
|
72
|
+
/** Bangumi 逐集档期不可信:只影响闸门 3,不影响是否剔除作品 */
|
|
73
|
+
markEpisodeUntrusted(entry: IndexEntry): void;
|
|
74
|
+
private episodeKey;
|
|
75
|
+
private put;
|
|
76
|
+
/** 取出仍在有效期内的记录;AniList 判定还要求 ID 没变 */
|
|
77
|
+
private active;
|
|
78
|
+
/** 主动落盘,插件卸载与调度结束时调用 */
|
|
79
|
+
save(): Promise<void>;
|
|
80
|
+
/** 同 Localizer:串行写盘,失败时保留 dirty 供下次重试 */
|
|
81
|
+
private flush;
|
|
82
|
+
private writeCache;
|
|
83
|
+
private prune;
|
|
84
|
+
private scheduleFlush;
|
|
85
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VerdictCache = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const constants_1 = require("../constants");
|
|
7
|
+
const utils_1 = require("../utils");
|
|
8
|
+
const VERDICT_REASONS = new Set([
|
|
9
|
+
'anilist-mismatch',
|
|
10
|
+
'anilist-finished',
|
|
11
|
+
'episode-mismatch',
|
|
12
|
+
'episode-untrusted',
|
|
13
|
+
'episode-finished',
|
|
14
|
+
]);
|
|
15
|
+
const DAY_MS = 86400000;
|
|
16
|
+
/** AniList 与 Bangumi 的末集日期都是 UTC 零点,判断「同一场」时必须换算成 JST 日期 */
|
|
17
|
+
function jstDay(ms) {
|
|
18
|
+
return Math.floor((ms + 9 * 3600000) / DAY_MS);
|
|
19
|
+
}
|
|
20
|
+
function isDropReason(reason) {
|
|
21
|
+
return reason === 'anilist-finished'
|
|
22
|
+
|| reason === 'episode-mismatch'
|
|
23
|
+
|| reason === 'episode-finished';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 「不采信 / 已剔除」判定的本地持久化缓存。
|
|
27
|
+
*
|
|
28
|
+
* 这些判定的输入(bangumi-data 的 `aniList` ID、逐集档期)很少变化,但原实现每次构建
|
|
29
|
+
* 都会重新查 AniList、重新算一遍,然后再次得出「这条记录不是这一季」或「这部已经播完」。
|
|
30
|
+
* 周表一次构建要为此多发几十个请求、多打几十行日志。
|
|
31
|
+
*
|
|
32
|
+
* 缓存只影响 daily / night / weekly / sub 的完结闸门,**不影响本季表**:季表本来就要
|
|
33
|
+
* 展示整季作品,不能因为「已完结」把它们藏起来。
|
|
34
|
+
*/
|
|
35
|
+
class VerdictCache {
|
|
36
|
+
ctx;
|
|
37
|
+
logger;
|
|
38
|
+
records = new Map();
|
|
39
|
+
cachePath;
|
|
40
|
+
loaded = false;
|
|
41
|
+
loadPromise = null;
|
|
42
|
+
dirty = false;
|
|
43
|
+
flushTimer = null;
|
|
44
|
+
flushing = null;
|
|
45
|
+
writeFailed = false;
|
|
46
|
+
constructor(ctx, logger) {
|
|
47
|
+
this.ctx = ctx;
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
this.cachePath = (0, path_1.resolve)(ctx.baseDir, ...constants_1.DATA_DIR, constants_1.VERDICT_CACHE_FILE);
|
|
50
|
+
}
|
|
51
|
+
/** 确保缓存已从磁盘读取;重复调用只读一次 */
|
|
52
|
+
ensure() {
|
|
53
|
+
if (this.loaded)
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
return this.load();
|
|
56
|
+
}
|
|
57
|
+
async load() {
|
|
58
|
+
if (this.loaded)
|
|
59
|
+
return;
|
|
60
|
+
if (this.loadPromise)
|
|
61
|
+
return this.loadPromise;
|
|
62
|
+
this.loadPromise = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const text = await fs_1.promises.readFile(this.cachePath, 'utf8');
|
|
65
|
+
const parsed = JSON.parse(text);
|
|
66
|
+
if (parsed?.version !== constants_1.VERDICT_CACHE_VERSION || !parsed.entries) {
|
|
67
|
+
this.logger.info('[verdict] 缓存版本 %s 与当前 %d 不符,本次重新构建', parsed?.version ?? '(无)', constants_1.VERDICT_CACHE_VERSION);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const record of Object.values(parsed.entries)) {
|
|
71
|
+
if (!record || typeof record.key !== 'string' || !VERDICT_REASONS.has(record.reason))
|
|
72
|
+
continue;
|
|
73
|
+
if (typeof record.expiresAt !== 'number' || typeof record.at !== 'number')
|
|
74
|
+
continue;
|
|
75
|
+
if (record.expiresAt <= Date.now())
|
|
76
|
+
continue;
|
|
77
|
+
this.records.set(record.key, record);
|
|
78
|
+
}
|
|
79
|
+
(0, utils_1.debugLog)('[verdict] 判定缓存载入完成,条目数: %d', this.records.size);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code !== 'ENOENT') {
|
|
83
|
+
this.logger.warn('[verdict] 读取判定缓存失败,将从空缓存开始:', error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.loaded = true;
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
return this.loadPromise;
|
|
91
|
+
}
|
|
92
|
+
/** 这条场次是否已被缓存判定为「应当剔除」 */
|
|
93
|
+
shouldDrop(entry, atMs) {
|
|
94
|
+
const record = this.active(entry);
|
|
95
|
+
if (!record)
|
|
96
|
+
return false;
|
|
97
|
+
if (record.reason === 'episode-mismatch')
|
|
98
|
+
return true;
|
|
99
|
+
if (record.reason === 'anilist-finished') {
|
|
100
|
+
return record.dropAfterMs !== null && atMs > record.dropAfterMs;
|
|
101
|
+
}
|
|
102
|
+
if (record.reason === 'episode-finished') {
|
|
103
|
+
return record.dropAfterDay !== null && jstDay(atMs) > record.dropAfterDay;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/** 是否应跳过这条条目的 AniList 状态请求(记录指向别季) */
|
|
108
|
+
shouldSkipAniList(entry) {
|
|
109
|
+
return this.active(entry)?.reason === 'anilist-mismatch';
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 是否应跳过这条条目的逐集档期完结判断。
|
|
113
|
+
*
|
|
114
|
+
* 与 AniList 判定分开存:一部作品可能同时「AniList 指向别季」和「Bangumi 逐集档期不可信」,
|
|
115
|
+
* 两条判定都要保留,不能互相覆盖,所以逐集档期用独立的 `#episodes` 键。
|
|
116
|
+
*/
|
|
117
|
+
shouldSkipEpisodeDates(entry) {
|
|
118
|
+
const record = this.records.get(this.episodeKey(entry));
|
|
119
|
+
return record?.reason === 'episode-untrusted' && record.expiresAt > Date.now();
|
|
120
|
+
}
|
|
121
|
+
/** 记录一次判定;同一部作品后出现的「剔除」记录可以覆盖较轻的「不采信」 */
|
|
122
|
+
record(entry, reason, patch = {}) {
|
|
123
|
+
this.put(entry.key, entry, reason, patch);
|
|
124
|
+
}
|
|
125
|
+
/** Bangumi 逐集档期不可信:只影响闸门 3,不影响是否剔除作品 */
|
|
126
|
+
markEpisodeUntrusted(entry) {
|
|
127
|
+
this.put(this.episodeKey(entry), entry, 'episode-untrusted');
|
|
128
|
+
}
|
|
129
|
+
episodeKey(entry) {
|
|
130
|
+
return `${entry.key}#episodes`;
|
|
131
|
+
}
|
|
132
|
+
put(cacheKey, entry, reason, patch = {}) {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const previous = this.records.get(cacheKey);
|
|
135
|
+
if (previous && previous.expiresAt > now) {
|
|
136
|
+
// 已经确认剔除的作品,不要再被一次「不采信」降级;反过来则可以
|
|
137
|
+
if (isDropReason(previous.reason) && !isDropReason(reason))
|
|
138
|
+
return;
|
|
139
|
+
// 相同内容不重复写盘
|
|
140
|
+
if (previous.reason === reason
|
|
141
|
+
&& previous.aniListId === entry.aniListId
|
|
142
|
+
&& previous.dropAfterMs === (patch.dropAfterMs ?? null)
|
|
143
|
+
&& previous.dropAfterDay === (patch.dropAfterDay ?? null)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const ttl = reason === 'anilist-mismatch' || reason === 'anilist-finished'
|
|
148
|
+
? constants_1.ANILIST_VERDICT_TTL_MS
|
|
149
|
+
: constants_1.EPISODE_VERDICT_TTL_MS;
|
|
150
|
+
this.records.set(cacheKey, {
|
|
151
|
+
key: cacheKey,
|
|
152
|
+
title: entry.title,
|
|
153
|
+
reason,
|
|
154
|
+
aniListId: entry.aniListId,
|
|
155
|
+
dropAfterMs: patch.dropAfterMs ?? null,
|
|
156
|
+
dropAfterDay: patch.dropAfterDay ?? null,
|
|
157
|
+
at: now,
|
|
158
|
+
expiresAt: now + ttl,
|
|
159
|
+
});
|
|
160
|
+
this.dirty = true;
|
|
161
|
+
(0, utils_1.debugLog)('[verdict] 记录判定: %s → %s', entry.title, reason);
|
|
162
|
+
this.scheduleFlush();
|
|
163
|
+
}
|
|
164
|
+
/** 取出仍在有效期内的记录;AniList 判定还要求 ID 没变 */
|
|
165
|
+
active(entry) {
|
|
166
|
+
const record = this.records.get(entry.key);
|
|
167
|
+
if (!record || record.expiresAt <= Date.now())
|
|
168
|
+
return undefined;
|
|
169
|
+
if ((record.reason === 'anilist-mismatch' || record.reason === 'anilist-finished')
|
|
170
|
+
&& record.aniListId !== entry.aniListId) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
return record;
|
|
174
|
+
}
|
|
175
|
+
/** 主动落盘,插件卸载与调度结束时调用 */
|
|
176
|
+
async save() {
|
|
177
|
+
if (this.flushTimer) {
|
|
178
|
+
this.flushTimer();
|
|
179
|
+
this.flushTimer = null;
|
|
180
|
+
}
|
|
181
|
+
await this.flush();
|
|
182
|
+
if (this.dirty && !this.flushing)
|
|
183
|
+
await this.flush();
|
|
184
|
+
}
|
|
185
|
+
/** 同 Localizer:串行写盘,失败时保留 dirty 供下次重试 */
|
|
186
|
+
flush() {
|
|
187
|
+
if (this.flushing)
|
|
188
|
+
return this.flushing;
|
|
189
|
+
if (!this.dirty)
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
this.flushing = this.writeCache().finally(() => {
|
|
192
|
+
this.flushing = null;
|
|
193
|
+
if (this.dirty && !this.writeFailed)
|
|
194
|
+
this.scheduleFlush();
|
|
195
|
+
});
|
|
196
|
+
return this.flushing;
|
|
197
|
+
}
|
|
198
|
+
async writeCache() {
|
|
199
|
+
this.dirty = false;
|
|
200
|
+
this.prune();
|
|
201
|
+
if (this.records.size > constants_1.VERDICT_CACHE_MAX_ENTRIES) {
|
|
202
|
+
const sorted = [...this.records.values()].sort((a, b) => a.expiresAt - b.expiresAt);
|
|
203
|
+
const drop = sorted.slice(0, sorted.length - Math.floor(constants_1.VERDICT_CACHE_MAX_ENTRIES / 2));
|
|
204
|
+
for (const record of drop)
|
|
205
|
+
this.records.delete(record.key);
|
|
206
|
+
}
|
|
207
|
+
const payload = { version: constants_1.VERDICT_CACHE_VERSION, entries: {} };
|
|
208
|
+
for (const [key, record] of this.records)
|
|
209
|
+
payload.entries[key] = record;
|
|
210
|
+
const temp = `${this.cachePath}.tmp`;
|
|
211
|
+
try {
|
|
212
|
+
await fs_1.promises.mkdir((0, path_1.dirname)(this.cachePath), { recursive: true });
|
|
213
|
+
await fs_1.promises.writeFile(temp, JSON.stringify(payload), 'utf8');
|
|
214
|
+
await fs_1.promises.rename(temp, this.cachePath);
|
|
215
|
+
this.writeFailed = false;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
this.dirty = true;
|
|
219
|
+
this.writeFailed = true;
|
|
220
|
+
this.logger.warn('[verdict] 写入判定缓存失败:', (0, utils_1.errorMessage)(error));
|
|
221
|
+
await fs_1.promises.rm(temp, { force: true }).catch(() => undefined);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
prune() {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
for (const [key, record] of this.records) {
|
|
227
|
+
if (record.expiresAt <= now)
|
|
228
|
+
this.records.delete(key);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
scheduleFlush() {
|
|
232
|
+
this.dirty = true;
|
|
233
|
+
if (this.flushTimer)
|
|
234
|
+
return;
|
|
235
|
+
this.flushTimer = this.ctx.setTimeout(() => {
|
|
236
|
+
this.flushTimer = null;
|
|
237
|
+
void this.flush();
|
|
238
|
+
}, 3000);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
exports.VerdictCache = VerdictCache;
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -42,6 +42,9 @@ npm i koishi-plugin-anime-schedule
|
|
|
42
42
|
TV / WEB 动画,展示首播时间。
|
|
43
43
|
- **现在时刻**:每日与深夜档的时间轴用一条虚线标出当前时刻,线以上已播出、线以下未播出。
|
|
44
44
|
- **完结判定**:以 AniList 为准,播完的作品自动从各表与订阅列表中移除。
|
|
45
|
+
- **判定缓存**:把「AniList 记录指向别季」「已确认完结」「逐集档期不可信」等稳定判定写入
|
|
46
|
+
`data/anime-schedule/verdicts.json`,后续构建不再重复请求与判断;AniList 判定 7 天、
|
|
47
|
+
Bangumi 档期判定 30 天后自动失效,只影响每日 / 深夜 / 本周 / 订阅表,不影响本季表。
|
|
45
48
|
- **时刻**:以 AniList 记录的时刻为准,查不到时按放送周期推算。
|
|
46
49
|
- **去重**:每类推送按「类型 + 目标 + 日期」去重,重启或补发都只发一次。
|
|
47
50
|
|