koishi-plugin-anime-schedule 1.1.2 → 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 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('其他'),
@@ -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
  /**
@@ -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
- if (dayGap !== null && dayGap > exports.MAX_DAY_GAP)
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 = new Map();
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 days = (0, utils_1.clamp)(Number.parseInt(daysText ?? '7', 10) || 7, 1, 31);
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.filter((row) => row.dispatchedAt >= dayStart && row.dispatchedAt < dayStart + 86400000).length} 条`,
620
+ `今日推送记录: ${dispatches.length} 条`,
615
621
  ].join('\n');
616
622
  }
617
623
  catch (error) {
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 digest.localizer.save().catch((error) => {
146
- logger.debug('[lifecycle] 保存本地化缓存失败: %s', (0, utils_1.errorMessage)(error));
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) {
@@ -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 让调用方退回周期推算。结果(含「搜不到」)在进程内缓存。
@@ -79,8 +79,22 @@ class AiringStatusService {
79
79
  ensure() {
80
80
  return Promise.resolve();
81
81
  }
82
- isFresh(entry, ttlDays) {
83
- return Date.now() - entry.cachedAt < ttlDays * 24 * 60 * 60 * 1000;
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 = (0, anilist_match_1.normalizeTitle)(target.title);
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
- return this.handleRequestError(error) ? null : [];
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)
@@ -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`。
@@ -280,6 +290,8 @@ export declare class DigestService {
280
290
  * @param limit 返回条数上限,`<= 0` 表示不限制
281
291
  */
282
292
  search(keyword: string, limit?: number): Promise<DigestItem[]>;
293
+ /** 按条目 ID 批量解析作品名;订阅列表一次取完,避免逐条串行请求 */
294
+ resolveSubscriptionNames(tids: string[]): Promise<Map<string, string | null>>;
283
295
  /** 按条目 ID 解析作品名;同时接受 `253104` 与 `bgm:253104` 两种写法 */
284
296
  resolveSubscriptionName(tid: string): Promise<string | null>;
285
297
  }
@@ -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 = candidates
500
- .map((candidate) => candidate.entry.aniListId)
501
- .filter((id) => id !== null);
502
- if (!ids.length)
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` 与「近期仍有排期」是纯本地判定,AniList 状态本来就要取,
783
- // 因此下面只为幸存者请求 Bangumi 详情。
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(predicted, options.fromMs, following);
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 同一套三段闸门:先本地、再 AniList、最后才为幸存者取 Bangumi 逐集档期
1130
+ // 与 buildAirings 同一套闸门:先复用判定缓存与本地依据,再 AniList、最后才为幸存者取 Bangumi 逐集档期
1131
+ const cached = this.dropCachedVerdicts(candidates);
1058
1132
  const following = this.followingKeys(startMs);
1059
- const locallyKept = this.dropLocallyFinished(candidates, startMs, following);
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
- const states = await this.resolveAiringStates([...entries.values()], now);
1219
- return [...entries].filter(([, entry]) => this.isEntryFinished(entry, states, now)).map(([tid]) => tid);
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 = entries
1226
- .map((entry) => entry.aniListId)
1227
- .filter((id) => id !== null);
1228
- if (!ids.length)
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)
@@ -1328,19 +1415,36 @@ class DigestService {
1328
1415
  return this.toItem(candidate, entry.bangumiId !== null ? subjects.get(entry.bangumiId) : undefined);
1329
1416
  });
1330
1417
  }
1418
+ /** 按条目 ID 批量解析作品名;订阅列表一次取完,避免逐条串行请求 */
1419
+ async resolveSubscriptionNames(tids) {
1420
+ await this.prepare();
1421
+ const result = new Map();
1422
+ const entries = new Map();
1423
+ const idByTid = new Map();
1424
+ for (const tid of new Set(tids)) {
1425
+ const matched = BGM_TID_RE.exec(tid) ?? BGM_ID_RE.exec(tid);
1426
+ if (!matched) {
1427
+ result.set(tid, null);
1428
+ continue;
1429
+ }
1430
+ const id = Number(matched[1]);
1431
+ const entry = this.index.bySubjectId(id);
1432
+ if (!entry) {
1433
+ result.set(tid, null);
1434
+ continue;
1435
+ }
1436
+ entries.set(tid, entry);
1437
+ idByTid.set(tid, id);
1438
+ }
1439
+ const subjects = await this.localizer.localizeByIds([...new Set(idByTid.values())], false);
1440
+ for (const [tid, entry] of entries) {
1441
+ result.set(tid, this.toItem({ entry, atMs: this.nextAirMs(entry, Date.now()), episode: 1 }, subjects.get(idByTid.get(tid))).name);
1442
+ }
1443
+ return result;
1444
+ }
1331
1445
  /** 按条目 ID 解析作品名;同时接受 `253104` 与 `bgm:253104` 两种写法 */
1332
1446
  async resolveSubscriptionName(tid) {
1333
- await this.prepare();
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;
1447
+ return (await this.resolveSubscriptionNames([tid])).get(tid) ?? null;
1344
1448
  }
1345
1449
  }
1346
1450
  exports.DigestService = DigestService;
@@ -398,10 +398,16 @@ exports.InfoService = InfoService;
398
398
  function flattenSummary(summary) {
399
399
  if (!summary)
400
400
  return '';
401
- return summary
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
- .join('');
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
- async flush() {
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
- if (!this.isDisplayFresh(cached) || (wantEpisodeTimes && cached.hasEpisodeTimes === false)) {
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 !== false);
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
- ...stale,
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: stale.hasEpisodeTimes,
303
- firstAirMs: stale.firstAirMs,
304
- lastAirMs: stale.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
- // 没有到期的推送就不碰数据库:清理与落盘每天各跑一次即可,否则 tick 每分钟一次 DELETE
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
- return this.ctx.database.get('anime_channel', { [field]: true });
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
  * 订阅作品的开播提醒。场次由周期规则推算,与定时列表推送面向不同时间点,也不受频道的列表
@@ -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;
@@ -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 的连接超时与响应超时)。
@@ -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
- if (!error || typeof error !== 'object')
63
- return null;
64
- const response = error.response;
65
- const status = response?.status;
66
- return typeof status === 'number' ? status : null;
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.2",
4
+ "version": "1.1.4",
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.233"
41
+ "bangumi-data": "^0.3.234"
42
42
  },
43
43
  "koishi": {
44
44
  "description": {
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