koishi-plugin-anime-schedule 1.1.1 → 1.1.2

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
@@ -117,7 +117,7 @@ exports.Config = koishi_1.Schema.intersect([
117
117
  .default('localized'),
118
118
  scoreTtlHours: koishi_1.Schema.number()
119
119
  .min(1).step(1)
120
- .description('评分缓存小时数')
120
+ .description('评分多久后台补取一次(小时),到期不阻塞构建')
121
121
  .default(constants_1.DEFAULT_SCORE_TTL_HOURS),
122
122
  cacheTtlDays: koishi_1.Schema.number()
123
123
  .min(1).step(1)
@@ -36,8 +36,13 @@ export declare const BANGUMI_DATA_FALLBACK_URLS: string[];
36
36
  export declare const BANGUMI_DATA_FILE = "bangumi-data.json";
37
37
  /** bangumi-data 回退缓存的目录(相对于 ctx.baseDir),与本地化缓存放在一起 */
38
38
  export declare const DATA_DIR: string[];
39
- /** 插件版本与主页,请求 Bangumi 时用于自报身份 */
40
- export declare const PLUGIN_VERSION = "1.0.0";
39
+ /**
40
+ * 插件版本与主页,请求 Bangumi 时用于自报身份。
41
+ *
42
+ * 版本号从 `package.json` 读:单独维护一份字面量必然会在发版时漏更新(它曾停在 1.0.0),
43
+ * 而 `package.json` 一定随包发布,读它不会失效。
44
+ */
45
+ export declare const PLUGIN_VERSION: string;
41
46
  export declare const PLUGIN_HOMEPAGE = "https://github.com/YuzuharaYuka/koishi-plugin-anime-schedule";
42
47
  /**
43
48
  * 请求 Bangumi 时使用的 User-Agent。
@@ -75,6 +80,9 @@ export declare const LOCALIZE_CACHE_FILE = "localize-cache.json";
75
80
  *
76
81
  * 比标题封面的 cacheTtlDays 短得多:新番开播头几周评分天天在变,样本量从个位数
77
82
  * 涨到几百,用 30 天缓存会一直显示「只有十几个人投票」时的噪声值。
83
+ *
84
+ * 到期只触发后台补取(`Localizer.kickScoreRefresh()`),不阻塞构建:评分是一张表的
85
+ * 附属信息,为它把整张表推迟十几秒不划算。
78
86
  */
79
87
  export declare const DEFAULT_SCORE_TTL_HOURS = 24;
80
88
  /**
@@ -151,6 +159,16 @@ export declare const MAX_SUPERSAMPLE = 5;
151
159
  */
152
160
  export declare const DAILY_OFFSET_MIN = -7;
153
161
  export declare const DAILY_OFFSET_MAX = 7;
162
+ /**
163
+ * 构建结果的复用窗口(毫秒)。
164
+ *
165
+ * 推送遍历频道时每个群都调一次构建,指令层也常被连着问同一张表。这些调用的差别只有
166
+ * 「已订阅」标记,其余部分(场次推算、AniList 状态、Bangumi 详情、封面地址)完全相同,
167
+ * 因此一份结果在窗口内可以重复用,订阅标记事后再按频道注入。
168
+ */
169
+ export declare const BUILD_CACHE_TTL_MS: number;
170
+ /** 构建结果同时留存几份(日/深夜/周/季 × 少量偏移),超出丢最早的一份 */
171
+ export declare const BUILD_CACHE_MAX_ENTRIES = 16;
154
172
  /** 注册到 canvas 的字体族名,与 config.render.fontFamily 的默认值保持一致 */
155
173
  export declare const DEFAULT_FONT_FAMILY = "Anime Schedule Sans";
156
174
  /**
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.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.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 主体 */
@@ -44,8 +44,21 @@ exports.BANGUMI_DATA_FALLBACK_URLS = [
44
44
  exports.BANGUMI_DATA_FILE = 'bangumi-data.json';
45
45
  /** bangumi-data 回退缓存的目录(相对于 ctx.baseDir),与本地化缓存放在一起 */
46
46
  exports.DATA_DIR = ['data', 'anime-schedule'];
47
- /** 插件版本与主页,请求 Bangumi 时用于自报身份 */
48
- exports.PLUGIN_VERSION = '1.0.0';
47
+ /**
48
+ * 插件版本与主页,请求 Bangumi 时用于自报身份。
49
+ *
50
+ * 版本号从 `package.json` 读:单独维护一份字面量必然会在发版时漏更新(它曾停在 1.0.0),
51
+ * 而 `package.json` 一定随包发布,读它不会失效。
52
+ */
53
+ exports.PLUGIN_VERSION = (() => {
54
+ try {
55
+ const pkg = require('../package.json');
56
+ return pkg.version ?? '0.0.0';
57
+ }
58
+ catch {
59
+ return '0.0.0';
60
+ }
61
+ })();
49
62
  exports.PLUGIN_HOMEPAGE = 'https://github.com/YuzuharaYuka/koishi-plugin-anime-schedule';
50
63
  /**
51
64
  * 请求 Bangumi 时使用的 User-Agent。
@@ -91,6 +104,9 @@ exports.LOCALIZE_CACHE_FILE = 'localize-cache.json';
91
104
  *
92
105
  * 比标题封面的 cacheTtlDays 短得多:新番开播头几周评分天天在变,样本量从个位数
93
106
  * 涨到几百,用 30 天缓存会一直显示「只有十几个人投票」时的噪声值。
107
+ *
108
+ * 到期只触发后台补取(`Localizer.kickScoreRefresh()`),不阻塞构建:评分是一张表的
109
+ * 附属信息,为它把整张表推迟十几秒不划算。
94
110
  */
95
111
  exports.DEFAULT_SCORE_TTL_HOURS = 24;
96
112
  /**
@@ -188,6 +204,16 @@ exports.MAX_SUPERSAMPLE = 5;
188
204
  */
189
205
  exports.DAILY_OFFSET_MIN = -7;
190
206
  exports.DAILY_OFFSET_MAX = 7;
207
+ /**
208
+ * 构建结果的复用窗口(毫秒)。
209
+ *
210
+ * 推送遍历频道时每个群都调一次构建,指令层也常被连着问同一张表。这些调用的差别只有
211
+ * 「已订阅」标记,其余部分(场次推算、AniList 状态、Bangumi 详情、封面地址)完全相同,
212
+ * 因此一份结果在窗口内可以重复用,订阅标记事后再按频道注入。
213
+ */
214
+ exports.BUILD_CACHE_TTL_MS = 5 * 60 * 1000;
215
+ /** 构建结果同时留存几份(日/深夜/周/季 × 少量偏移),超出丢最早的一份 */
216
+ exports.BUILD_CACHE_MAX_ENTRIES = 16;
191
217
  /** 注册到 canvas 的字体族名,与 config.render.fontFamily 的默认值保持一致 */
192
218
  exports.DEFAULT_FONT_FAMILY = 'Anime Schedule Sans';
193
219
  /**
package/lib/handlers.js CHANGED
@@ -92,6 +92,8 @@ function platformOf(session) {
92
92
  /** 构建一次推送内容并回复当前会话 */
93
93
  async function replyDigest(deps, session, digest) {
94
94
  const { config, renderer, logger } = deps;
95
+ // 内容已经拿到手,评分过期的条目趁出图这段时间在后台补,不占用这次指令的等待
96
+ deps.digest.localizer.kickScoreRefresh();
95
97
  if (!digest.groups.length) {
96
98
  await session.send([digest.header, digest.summary].filter(Boolean).join('\n'));
97
99
  return;
@@ -80,7 +80,7 @@ class Renderer {
80
80
  const started = Date.now();
81
81
  const canvas = await this.draw(payload);
82
82
  const encoded = await canvas.encode('jpeg', this.config.render.jpegQuality);
83
- this.logger.debug('[render] %s 出图完成 (%dms),尺寸 %dx%d (超采样 %dx),%d bytes (jpeg q=%d)', payload.kind, Date.now() - started, canvas.width, canvas.height, encoded.length, this.config.render.jpegQuality);
83
+ this.logger.debug('[render] %s 出图完成(%dms),尺寸 %dx%d(超采样 %dx),%d bytes(jpeg q=%d)', payload.kind, Date.now() - started, canvas.width, canvas.height, this.scale, encoded.length, this.config.render.jpegQuality);
84
84
  releaseSurface(canvas);
85
85
  return encoded;
86
86
  }
@@ -16,6 +16,10 @@ export declare class DigestService {
16
16
  /** AniList 播出状态查询;未注入时退化为「按 Bangumi 逐集档期判断」 */
17
17
  private airing?;
18
18
  readonly localizer: Localizer;
19
+ /** 已构建结果的短缓存,见 `shared()` */
20
+ private readonly recentBuilds;
21
+ /** 同一份内容正被构建时,后来的调用直接等它,不重复构建 */
22
+ private readonly pendingBuilds;
19
23
  constructor(ctx: Context, config: Config, logger: Logger, index: BangumiDataIndex, http: HttpClient,
20
24
  /** AniList 播出状态查询;未注入时退化为「按 Bangumi 逐集档期判断」 */
21
25
  airing?: AiringStatusService | undefined);
@@ -172,6 +176,14 @@ export declare class DigestService {
172
176
  /** 每种表的分组方式 */
173
177
  private groupOf;
174
178
  private emptyTextOf;
179
+ /**
180
+ * 取共享构建结果,再把本频道的订阅标记补上。
181
+ *
182
+ * @param key 只由「内容口径」决定:表种、偏移、以及 `now` 落在哪一天/哪一季。同一个键在
183
+ * `BUILD_CACHE_TTL_MS` 内只构建一次。
184
+ */
185
+ private shared;
186
+ private rememberBuild;
175
187
  /**
176
188
  * 每日更新表,`offset` 为相对今天的日期偏移(-1 表示前一天)。
177
189
  *
@@ -205,7 +217,12 @@ export declare class DigestService {
205
217
  * 主动指定的),但已播完的必须滤掉:周期规则无限重复,旧季番照样能推算出「第 287 集」。
206
218
  */
207
219
  upcomingAirings(tids: string[], days: number, now?: number): Promise<DigestItem[]>;
208
- /** 按类型构建,供调度器与指令共用;订阅标记按目标频道计算 */
220
+ /**
221
+ * 按类型构建,供调度器与指令共用。
222
+ *
223
+ * 除订阅排期外都走 `shared()`:同一份内容在窗口内只构建一次,逐群推送与重复指令只多付
224
+ * 一次注入「已订阅」标记的工夫。
225
+ */
209
226
  build(kind: DigestKind, platform: string, channelId: string, now?: number): Promise<Digest>;
210
227
  /**
211
228
  * 收尾:套上上限、算统计行、拼出最终 `Digest`。`now` 必须由调用方传入而**不能取
@@ -66,6 +66,21 @@ const EMPTY_SETS = new Set();
66
66
  function defaultTidOf(entry) {
67
67
  return entry.bangumiId === null ? null : `bgm:${entry.bangumiId}`;
68
68
  }
69
+ /**
70
+ * 把共享构建结果里的「已订阅」标记换成调用方频道的集合。
71
+ *
72
+ * 共享结果按空集合构建,因此条目上的标记全是 `false`,这里只需给命中的条目翻一个布尔值,
73
+ * 不必重排、重算摘要——那些都只跟条目内容有关。
74
+ */
75
+ function withSubscribed(digest, subscribed) {
76
+ if (!subscribed.size || !digest.groups.length)
77
+ return digest;
78
+ const groups = digest.groups.map((group) => ({
79
+ ...group,
80
+ items: group.items.map((item) => (subscribed.has(item.tid) ? { ...item, subscribed: true } : item)),
81
+ }));
82
+ return { ...digest, groups };
83
+ }
69
84
  /**
70
85
  * 推送内容构建服务。
71
86
  * 播出时刻与集数全部由 bangumi-data 的周期规则推算,构建过程不产生番组表请求。
@@ -76,6 +91,10 @@ class DigestService {
76
91
  index;
77
92
  airing;
78
93
  localizer;
94
+ /** 已构建结果的短缓存,见 `shared()` */
95
+ recentBuilds = new Map();
96
+ /** 同一份内容正被构建时,后来的调用直接等它,不重复构建 */
97
+ pendingBuilds = new Map();
79
98
  constructor(ctx, config, logger, index, http,
80
99
  /** AniList 播出状态查询;未注入时退化为「按 Bangumi 逐集档期判断」 */
81
100
  airing) {
@@ -844,6 +863,38 @@ class DigestService {
844
863
  return this.messages.emptyWeekly;
845
864
  return this.messages.emptyDaily;
846
865
  }
866
+ /**
867
+ * 取共享构建结果,再把本频道的订阅标记补上。
868
+ *
869
+ * @param key 只由「内容口径」决定:表种、偏移、以及 `now` 落在哪一天/哪一季。同一个键在
870
+ * `BUILD_CACHE_TTL_MS` 内只构建一次。
871
+ */
872
+ async shared(key, create, subscribed) {
873
+ const hit = this.recentBuilds.get(key);
874
+ if (hit && Date.now() - hit.at < constants_1.BUILD_CACHE_TTL_MS)
875
+ return withSubscribed(hit.digest, subscribed);
876
+ let pending = this.pendingBuilds.get(key);
877
+ if (!pending) {
878
+ pending = create()
879
+ .then((digest) => {
880
+ this.rememberBuild(key, digest);
881
+ return digest;
882
+ })
883
+ .finally(() => this.pendingBuilds.delete(key));
884
+ this.pendingBuilds.set(key, pending);
885
+ }
886
+ return withSubscribed(await pending, subscribed);
887
+ }
888
+ rememberBuild(key, digest) {
889
+ // 先删后加:同一个键再构建一次时把它挪到队尾,淘汰的永远是最久没用过的那份
890
+ this.recentBuilds.delete(key);
891
+ this.recentBuilds.set(key, { at: Date.now(), digest });
892
+ for (const oldest of this.recentBuilds.keys()) {
893
+ if (this.recentBuilds.size <= constants_1.BUILD_CACHE_MAX_ENTRIES)
894
+ break;
895
+ this.recentBuilds.delete(oldest);
896
+ }
897
+ }
847
898
  /**
848
899
  * 每日更新表,`offset` 为相对今天的日期偏移(-1 表示前一天)。
849
900
  *
@@ -852,17 +903,20 @@ class DigestService {
852
903
  async buildDaily(offset = this.config.push.dailyOffsetDays, subscribed = EMPTY_SETS, now = Date.now()) {
853
904
  // 与配置项 `.min()/.max()` 共用同一对边界,避免「配置页能填、这里又被夹一次」
854
905
  const days = (0, utils_1.clamp)(offset, constants_1.DAILY_OFFSET_MIN, constants_1.DAILY_OFFSET_MAX);
855
- return this.buildAirings('daily', subscribed, now, (at) => {
906
+ const key = `daily|${days}|${(0, time_1.today)(this.timeZone, now)}`;
907
+ const create = () => this.buildAirings('daily', EMPTY_SETS, now, (at) => {
856
908
  const startMs = (0, time_1.getDayStartMs)((0, time_1.addDays)((0, time_1.today)(this.timeZone, at), days, this.timeZone), this.timeZone);
857
909
  return { fromMs: startMs, toMs: startMs + 86400000 - 1, now: startMs };
858
910
  });
911
+ return this.shared(key, create, subscribed);
859
912
  }
860
913
  /**
861
914
  * 深夜档更新表:覆盖「今晚 21:00 之后」与「次日 6:00 之前」两段。凌晨场次按实际播出时刻
862
915
  * 展示,因此熬夜追更时一眼就能看到现在之后还有什么可看。
863
916
  */
864
917
  async buildNight(subscribed = EMPTY_SETS, now = Date.now()) {
865
- return this.buildAirings('night', subscribed, now, (at) => {
918
+ const key = `night|${(0, time_1.today)(this.timeZone, now)}`;
919
+ const create = () => this.buildAirings('night', EMPTY_SETS, now, (at) => {
866
920
  const date = (0, time_1.today)(this.timeZone, at);
867
921
  const eveningStartMs = (0, time_1.getDayStartMs)(date, this.timeZone) + EVENING_START_HOUR * 3600000;
868
922
  const tomorrowStartMs = (0, time_1.getDayStartMs)((0, time_1.addDays)(date, 1, this.timeZone), this.timeZone);
@@ -873,16 +927,19 @@ class DigestService {
873
927
  now: eveningStartMs,
874
928
  };
875
929
  });
930
+ return this.shared(key, create, subscribed);
876
931
  }
877
932
  /** 本周更新表,覆盖今天起七天,每部作品只占一条 */
878
933
  async buildWeekly(subscribed = EMPTY_SETS, now = Date.now()) {
879
- return this.buildAirings('weekly', subscribed, now, (at) => {
934
+ const key = `weekly|${(0, time_1.today)(this.timeZone, now)}`;
935
+ const create = () => this.buildAirings('weekly', EMPTY_SETS, now, (at) => {
880
936
  const start = (0, time_1.today)(this.timeZone, at);
881
937
  const startMs = (0, time_1.getDayStartMs)(start, this.timeZone);
882
938
  // 七天整:到「第 8 天 0 点」为止
883
939
  const endMs = (0, time_1.getDayStartMs)((0, time_1.addDays)(start, 7, this.timeZone), this.timeZone);
884
940
  return { fromMs: startMs, toMs: endMs - 1, onePerTitle: true, now: startMs };
885
941
  });
942
+ return this.shared(key, create, subscribed);
886
943
  }
887
944
  /**
888
945
  * 本季(或指定季度)的新番概览:收录该季首播的全部 TV / WEB 动画,不剔除已播完的作品,
@@ -893,7 +950,9 @@ class DigestService {
893
950
  async buildSeason(seasonOffset = 0, subscribed = EMPTY_SETS, now = Date.now()) {
894
951
  await this.prepare();
895
952
  const { year, startMonth } = this.resolveSeason(seasonOffset, now);
896
- return this.buildSeasonOf(year, startMonth, subscribed, now);
953
+ // 同一季的内容与「今天」无关,键里不必带日期
954
+ const key = `season|${year}|${startMonth}`;
955
+ return this.shared(key, () => this.buildSeasonOf(year, startMonth, EMPTY_SETS, now), subscribed);
897
956
  }
898
957
  /** 由「相对当前季度的偏移」算出目标季度 */
899
958
  resolveSeason(offset, now) {
@@ -1016,7 +1075,12 @@ class DigestService {
1016
1075
  (0, utils_1.debugLog)('[digest] 订阅排期: %d 条订阅 → 未来 %d 天内 %d 场', tids.length, days, items.length);
1017
1076
  return items.sort((a, b) => a.atMs - b.atMs);
1018
1077
  }
1019
- /** 按类型构建,供调度器与指令共用;订阅标记按目标频道计算 */
1078
+ /**
1079
+ * 按类型构建,供调度器与指令共用。
1080
+ *
1081
+ * 除订阅排期外都走 `shared()`:同一份内容在窗口内只构建一次,逐群推送与重复指令只多付
1082
+ * 一次注入「已订阅」标记的工夫。
1083
+ */
1020
1084
  async build(kind, platform, channelId, now = Date.now()) {
1021
1085
  const subscribed = await this.subscribedKeys(platform, channelId);
1022
1086
  if (kind === 'daily')
@@ -1024,7 +1088,7 @@ class DigestService {
1024
1088
  if (kind === 'night')
1025
1089
  return this.buildNight(subscribed, now);
1026
1090
  if (kind === 'weekly')
1027
- return this.buildWeekly(subscribed);
1091
+ return this.buildWeekly(subscribed, now);
1028
1092
  if (kind === 'season')
1029
1093
  return this.buildSeason(0, subscribed, now);
1030
1094
  return this.buildSubscription(platform, channelId, now);
@@ -45,6 +45,9 @@ export declare class Localizer {
45
45
  private loadPromise;
46
46
  private dirty;
47
47
  private flushTimer;
48
+ /** 本次构建里评分过期、等出图之后在后台补取的条目 */
49
+ private readonly pendingScores;
50
+ private refreshingScores;
48
51
  constructor(ctx: Context, config: Config, logger: Logger, http: HttpClient);
49
52
  /** 是否使用中文名与评分展示(详情本身始终获取,用于判断是否已播完) */
50
53
  get enabled(): boolean;
@@ -58,12 +61,15 @@ export declare class Localizer {
58
61
  /** 主动落盘,插件卸载与调度结束后调用 */
59
62
  save(): Promise<void>;
60
63
  /**
61
- * 这一条缓存是否还够用。标题封面与评分分开计保质期:前者几乎不变(`cacheTtlDays`),后者在
62
- * 新番开播头几周天天在变(`scoreTtlHours`)。
64
+ * 标题、封面与首末集日期是否还能用。这几项基本不变,只受 `cacheTtlDays` 管。
63
65
  *
64
- * @param wantEpisodeTimes 需要逐集档期时,缺档期的条目即使标题还新鲜也得重取一次。
66
+ * 评分不在其中:它只在开播头几周天天变,而重取一次评分要走整条
67
+ * `GET /v0/subjects/{id}` 请求。让评分过期去卡构建,等于为了一个数字把整张表推迟十几秒,
68
+ * 因此过期评分先照旧展示,构建结束后再由 `kickScoreRefresh()` 在后台补。
65
69
  */
66
- private isFresh;
70
+ private isDisplayFresh;
71
+ /** 评分是否该重取;「条目不存在」的占位记录没有评分,不参与 */
72
+ private needsScoreRefresh;
67
73
  /**
68
74
  * 批量取回条目详情,返回「条目 ID → 详情」。未命中的条目不会出现在结果里,调用方按
69
75
  * 「取不到就退化为日文原名」处理。
@@ -73,6 +79,15 @@ export declare class Localizer {
73
79
  * `hasEpisodeTimes: false`,日后真有需要时会自动补取。
74
80
  */
75
81
  localizeByIds(ids: number[], wantEpisodeTimes?: boolean): Promise<Map<number, LocalizeCacheEntry>>;
82
+ /**
83
+ * 把这次用到的、评分过期的条目在后台重取一遍。出图之后再调,让用户先看到内容。
84
+ *
85
+ * 不写在 `localizeByIds` 里自动跑,是因为快照与预览脚本要的结果必须只取决于本地缓存:
86
+ * 后台刷新会让同一份缓存在不同时刻构建出不同的评分。
87
+ */
88
+ kickScoreRefresh(): void;
89
+ /** 只补评分相关字段;逐集档期是另一笔请求,不在这里重取 */
90
+ private refreshScores;
76
91
  /** 按条目 ID 取单个条目详情;条目不存在(404)或请求失败时返回 null */
77
92
  private fetchSubject;
78
93
  /**
@@ -60,6 +60,9 @@ class Localizer {
60
60
  loadPromise = null;
61
61
  dirty = false;
62
62
  flushTimer = null;
63
+ /** 本次构建里评分过期、等出图之后在后台补取的条目 */
64
+ pendingScores = new Set();
65
+ refreshingScores = false;
63
66
  constructor(ctx, config, logger, http) {
64
67
  this.ctx = ctx;
65
68
  this.config = config;
@@ -156,25 +159,21 @@ class Localizer {
156
159
  await this.flush();
157
160
  }
158
161
  /**
159
- * 这一条缓存是否还够用。标题封面与评分分开计保质期:前者几乎不变(`cacheTtlDays`),后者在
160
- * 新番开播头几周天天在变(`scoreTtlHours`)。
162
+ * 标题、封面与首末集日期是否还能用。这几项基本不变,只受 `cacheTtlDays` 管。
161
163
  *
162
- * @param wantEpisodeTimes 需要逐集档期时,缺档期的条目即使标题还新鲜也得重取一次。
164
+ * 评分不在其中:它只在开播头几周天天变,而重取一次评分要走整条
165
+ * `GET /v0/subjects/{id}` 请求。让评分过期去卡构建,等于为了一个数字把整张表推迟十几秒,
166
+ * 因此过期评分先照旧展示,构建结束后再由 `kickScoreRefresh()` 在后台补。
163
167
  */
164
- isFresh(entry, wantEpisodeTimes) {
165
- if (entry.missing) {
166
- // 「不存在」也是有效结果:同一季内不必反复去问一个不会出现的条目
167
- return Date.now() - entry.cachedAt < this.config.localize.cacheTtlDays * 24 * 60 * 60 * 1000;
168
- }
169
- // 取过条目详情、却没取逐集档期,而这次需要档期 → 必须重取。旧版缓存没有 hasEpisodeTimes 字段,按「取过」处理。
170
- if (wantEpisodeTimes && entry.hasEpisodeTimes === false)
171
- return false;
172
- // 评分单独算保质期:到期就重取详情,重取时仍按调用方的需要决定要不要连档期一起取
173
- const scoreAt = entry.scoreCachedAt ?? entry.cachedAt;
174
- if (Date.now() - scoreAt >= this.config.localize.scoreTtlHours * 60 * 60 * 1000)
175
- return false;
168
+ isDisplayFresh(entry) {
176
169
  return Date.now() - entry.cachedAt < this.config.localize.cacheTtlDays * 24 * 60 * 60 * 1000;
177
170
  }
171
+ /** 评分是否该重取;「条目不存在」的占位记录没有评分,不参与 */
172
+ needsScoreRefresh(entry) {
173
+ if (entry.missing)
174
+ return false;
175
+ return Date.now() - (entry.scoreCachedAt ?? entry.cachedAt) >= this.config.localize.scoreTtlHours * 60 * 60 * 1000;
176
+ }
178
177
  /**
179
178
  * 批量取回条目详情,返回「条目 ID → 详情」。未命中的条目不会出现在结果里,调用方按
180
179
  * 「取不到就退化为日文原名」处理。
@@ -203,7 +202,10 @@ class Localizer {
203
202
  }
204
203
  // 过期条目也先返回,避免一次请求失败就让整张表掉封面
205
204
  result.set(id, cached);
206
- if (!this.isFresh(cached, wantEpisodeTimes)) {
205
+ if (this.needsScoreRefresh(cached))
206
+ this.pendingScores.add(id);
207
+ // 只有展示数据过期、或这次要档期而缓存里没有档期时才阻塞构建
208
+ if (!this.isDisplayFresh(cached) || (wantEpisodeTimes && cached.hasEpisodeTimes === false)) {
207
209
  wanted.push(id);
208
210
  previous.set(id, cached);
209
211
  }
@@ -261,6 +263,53 @@ class Localizer {
261
263
  }
262
264
  return result;
263
265
  }
266
+ /**
267
+ * 把这次用到的、评分过期的条目在后台重取一遍。出图之后再调,让用户先看到内容。
268
+ *
269
+ * 不写在 `localizeByIds` 里自动跑,是因为快照与预览脚本要的结果必须只取决于本地缓存:
270
+ * 后台刷新会让同一份缓存在不同时刻构建出不同的评分。
271
+ */
272
+ kickScoreRefresh() {
273
+ if (this.refreshingScores || !this.pendingScores.size)
274
+ return;
275
+ const ids = [...this.pendingScores];
276
+ this.pendingScores.clear();
277
+ this.refreshingScores = true;
278
+ void this.refreshScores(ids).finally(() => {
279
+ this.refreshingScores = false;
280
+ });
281
+ }
282
+ /** 只补评分相关字段;逐集档期是另一笔请求,不在这里重取 */
283
+ async refreshScores(ids) {
284
+ const started = Date.now();
285
+ let ok = 0;
286
+ await (0, utils_1.mapLimit)(ids, constants_1.LOCALIZE_CONCURRENCY, async (id) => {
287
+ const stale = this.memory.get(id);
288
+ // 排队期间可能已被别的构建刷新过
289
+ if (!stale || !this.needsScoreRefresh(stale))
290
+ return;
291
+ const subject = await this.fetchSubject(id);
292
+ if (!subject)
293
+ return;
294
+ ok++;
295
+ this.memory.set(id, {
296
+ ...stale,
297
+ title: subject.name_cn?.trim() || subject.name,
298
+ coverUrl: subject.images?.large ?? subject.images?.common ?? '',
299
+ score: computeScore(subject.rating),
300
+ ratingTotal: typeof subject.rating?.total === 'number' ? subject.rating.total : null,
301
+ totalEpisodes: resolveEpisodeTotal(subject),
302
+ hasEpisodeTimes: stale.hasEpisodeTimes,
303
+ firstAirMs: stale.firstAirMs,
304
+ lastAirMs: stale.lastAirMs,
305
+ cachedAt: Date.now(),
306
+ scoreCachedAt: Date.now(),
307
+ });
308
+ this.scheduleFlush();
309
+ });
310
+ if (ok)
311
+ (0, utils_1.debugLog)('[localize] 后台补评分: %d 部(%dms)', ok, Date.now() - started);
312
+ }
264
313
  /** 按条目 ID 取单个条目详情;条目不存在(404)或请求失败时返回 null */
265
314
  async fetchSubject(id) {
266
315
  const url = `${constants_1.BANGUMI_API_HOST}/v0/subjects/${id}`;
@@ -163,8 +163,9 @@ class Scheduler {
163
163
  const key = (0, dispatch_1.digestKey)(kind, date);
164
164
  for (const channel of channels) {
165
165
  try {
166
- // 必须逐群构建:订阅集合按 `platform:channelId` 算,「已订阅」气泡不能跨群复用,
167
- // 否则除第一个群以外都会显示第一个群的订阅标记
166
+ // 逐群构建,但除订阅标记外的部分由 `DigestService.shared()` 复用:订阅集合按
167
+ // `platform:channelId` 算,「已订阅」气泡不能跨群复用,否则除第一个群以外都会
168
+ // 显示第一个群的订阅标记
168
169
  const current = await this.digest.build(kind, channel.platform, channel.channelId, now);
169
170
  if (!current.groups.length) {
170
171
  result.skipped++;
@@ -185,6 +186,8 @@ class Scheduler {
185
186
  this.logger.warn('[push] 向 %s:%s 发送 %s 失败:', channel.platform, channel.channelId, constants_1.DIGEST_LABELS[kind], error);
186
187
  }
187
188
  }
189
+ // 推送内容已经发完,评分过期的条目在后台补,不拖慢这一轮推送
190
+ this.digest.localizer.kickScoreRefresh();
188
191
  if (result.sent === 0 && result.failed === 0) {
189
192
  this.logger.info('[push] %s: 本次没有符合条件的作品,未发送任何消息', constants_1.DIGEST_LABELS[kind]);
190
193
  return result;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-anime-schedule",
3
3
  "description": "番剧时间表与订阅提醒:提供日 / 周 / 季三表,支持定时推送、指定订阅、搜索和详情展示",
4
- "version": "1.1.1",
4
+ "version": "1.1.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -60,7 +60,7 @@ npm i koishi-plugin-anime-schedule
60
60
  | `output.infoKeywordLimit` | `20` | `anime.info` 按关键词最多出几部,`0` 不限制 |
61
61
  | `output.showScore` | `true` | 显示评分,评价不足 10 人时隐藏 |
62
62
  | `localize.titleStyle` | `localized` | `localized` 仅中文 / `both` 中文(日文原名)/ `original` 仅日文原名 |
63
- | `localize.scoreTtlHours` | `24` | 评分与评价人数的缓存小时数 |
63
+ | `localize.scoreTtlHours` | `24` | 评分与评价人数的后台刷新间隔,到期不阻塞出图 |
64
64
  | `render.jpegQuality` | `100` | 图片编码质量,调低可减小体积 |
65
65
  | `render.supersample` | `3` | 超采样倍数,提升小字与封面清晰度;`1` 最省体积 |
66
66
  | `advanced.debug` | `false` | 输出调试日志 |