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 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
  /**
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) {
@@ -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`。
@@ -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)
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-anime-schedule",
3
3
  "description": "番剧时间表与订阅提醒:提供日 / 周 / 季三表,支持定时推送、指定订阅、搜索和详情展示",
4
- "version": "1.1.3",
4
+ "version": "1.1.4",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
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