koishi-plugin-anime-schedule 1.1.0 → 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
@@ -65,7 +65,7 @@ exports.Config = koishi_1.Schema.intersect([
65
65
  koishi_1.Schema.object({
66
66
  content: koishi_1.Schema.object({
67
67
  maxItems: koishi_1.Schema.number()
68
- .min(0).step(10)
68
+ .min(0).step(1)
69
69
  .description('单次推送最多显示多少部,0 不限制')
70
70
  .default(0),
71
71
  longRunningThreshold: koishi_1.Schema.number()
@@ -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)
@@ -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('其他'),
@@ -193,6 +193,10 @@ function normalizeConfig(config) {
193
193
  const time = (value, fallback) => (typeof value === 'string' && TIME_PATTERN.test(value) ? value : fallback);
194
194
  // 旧版本的 `both` 形态已移除:非 `text` 一律按图片处理
195
195
  const format = config.output?.format === 'text' ? 'text' : 'image';
196
+ // 轮询间隔不能大于提前量:调度器每 poll 分钟检查一次 [now, now+lookahead] 窗口,
197
+ // 间隔更大时两次检查之间会留下覆盖不到的空档,场次静默漏发
198
+ const lookahead = int(config.push?.updateLookaheadMinutes, 30, 1);
199
+ const poll = int(config.push?.updatePollMinutes, 10, 1);
196
200
  return {
197
201
  ...config,
198
202
  timeZone: (0, time_1.resolveTimeZone)(config.timeZone, 'Asia/Shanghai'),
@@ -204,8 +208,8 @@ function normalizeConfig(config) {
204
208
  nightTime: time(config.push?.nightTime, '21:00'),
205
209
  weeklyTime: time(config.push?.weeklyTime, '08:00'),
206
210
  seasonTime: time(config.push?.seasonTime, '08:00'),
207
- updateLookaheadMinutes: int(config.push?.updateLookaheadMinutes, 30, 1),
208
- updatePollMinutes: int(config.push?.updatePollMinutes, 10, 1),
211
+ updateLookaheadMinutes: lookahead,
212
+ updatePollMinutes: Math.min(poll, lookahead),
209
213
  },
210
214
  content: {
211
215
  maxItems: int(config.content?.maxItems, 0),
@@ -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
  /**
@@ -112,6 +120,14 @@ export declare const COVER_MAX_BYTES: number;
112
120
  export declare const COVER_CACHE_DIR: string[];
113
121
  export declare const COVER_CACHE_TTL_MS: number;
114
122
  export declare const COVER_CACHE_MAX_FILES = 1200;
123
+ /**
124
+ * 内存里保留的解码后封面总量上限(按 RGBA 字节算)。
125
+ *
126
+ * 单张 800px 的封面约 1.8MB,96MB 约合 50 张。插件是常驻进程,封面缓存若无上限会随
127
+ * 渲染过的作品数一路增长;这里只保证「短时间内重复出图不必重新解码」,
128
+ * 超出部分照常回落到磁盘缓存。
129
+ */
130
+ export declare const COVER_MEMORY_MAX_BYTES: number;
115
131
  /** 图片渲染用的字体候选,按平台常见路径排列,找不到时回退到运行环境默认字体 */
116
132
  export declare const FONT_CANDIDATES: string[];
117
133
  /** 加粗字体候选,找不到时由绘制层用描边模拟粗体 */
@@ -143,6 +159,16 @@ export declare const MAX_SUPERSAMPLE = 5;
143
159
  */
144
160
  export declare const DAILY_OFFSET_MIN = -7;
145
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;
146
172
  /** 注册到 canvas 的字体族名,与 config.render.fontFamily 的默认值保持一致 */
147
173
  export declare const DEFAULT_FONT_FAMILY = "Anime Schedule Sans";
148
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_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
  /**
@@ -128,6 +144,14 @@ exports.COVER_MAX_BYTES = 4 * 1024 * 1024;
128
144
  exports.COVER_CACHE_DIR = ['data', 'anime-schedule', 'covers'];
129
145
  exports.COVER_CACHE_TTL_MS = 21 * 24 * 60 * 60 * 1000;
130
146
  exports.COVER_CACHE_MAX_FILES = 1200;
147
+ /**
148
+ * 内存里保留的解码后封面总量上限(按 RGBA 字节算)。
149
+ *
150
+ * 单张 800px 的封面约 1.8MB,96MB 约合 50 张。插件是常驻进程,封面缓存若无上限会随
151
+ * 渲染过的作品数一路增长;这里只保证「短时间内重复出图不必重新解码」,
152
+ * 超出部分照常回落到磁盘缓存。
153
+ */
154
+ exports.COVER_MEMORY_MAX_BYTES = 96 * 1024 * 1024;
131
155
  /** 图片渲染用的字体候选,按平台常见路径排列,找不到时回退到运行环境默认字体 */
132
156
  exports.FONT_CANDIDATES = [
133
157
  'C:/Windows/Fonts/msyh.ttc',
@@ -180,6 +204,16 @@ exports.MAX_SUPERSAMPLE = 5;
180
204
  */
181
205
  exports.DAILY_OFFSET_MIN = -7;
182
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;
183
217
  /** 注册到 canvas 的字体族名,与 config.render.fontFamily 的默认值保持一致 */
184
218
  exports.DEFAULT_FONT_FAMILY = 'Anime Schedule Sans';
185
219
  /**
@@ -61,6 +61,8 @@ export declare class BangumiDataIndex {
61
61
  private sourceLabel;
62
62
  private loaded;
63
63
  private loadPromise;
64
+ /** 检索用的折叠名缓存,见 `searchables()` */
65
+ private searchable;
64
66
  private readonly loader;
65
67
  /** 补充映射命中的条数,用于日志 */
66
68
  private mappedCount;
@@ -100,18 +102,24 @@ export declare class BangumiDataIndex {
100
102
  /** 按 Bangumi 条目 ID 取索引条目 */
101
103
  bySubjectId(id: number): IndexEntry | null;
102
104
  /**
103
- * 按关键词检索索引层。打分规则(高到低):完整标题完全相等(折叠后)→ 标题以关键词开头 →
104
- * 标题包含关键词 → 关键词的每个字符都按顺序出现在标题里(子序列,用于容忍译名与原文的
105
- * 字序差异)。除完全相等外,越短的标题得分越高。
106
- */
107
- /**
108
- * 按标题检索,按相关度降序返回。
105
+ * 按关键词检索索引层。
106
+ *
107
+ * 打分规则(高到低):完整标题完全相等(折叠后)→ 标题以关键词开头 → 标题包含关键词 →
108
+ * 关键词的每个字符都按顺序出现在标题里(子序列,用于容忍译名与原文的字序差异)。
109
+ * 除完全相等外,越短的标题得分越高。
109
110
  *
110
111
  * @param limit 返回条数上限,`<= 0` 表示不限制
111
112
  */
112
113
  search(keyword: string, limit?: number): IndexEntry[];
113
114
  /** 一条作品的全部可检索名称 */
114
115
  private namesOf;
116
+ /**
117
+ * 检索用的「折叠名」列表,首次检索时构建一次。
118
+ *
119
+ * `foldTitle` 要做字符映射、NFKC、大小写与两条 Unicode 正则,八个字的名字约 10µs;
120
+ * 全库两万多个名字每个字都重算一遍是 130ms 级——而索引在进程内不变,折叠结果也不变。
121
+ */
122
+ private searchables;
115
123
  /** 把原始清单展开成索引条目 */
116
124
  private apply;
117
125
  }
@@ -24,6 +24,8 @@ class BangumiDataIndex {
24
24
  sourceLabel = '';
25
25
  loaded = false;
26
26
  loadPromise = null;
27
+ /** 检索用的折叠名缓存,见 `searchables()` */
28
+ searchable = null;
27
29
  loader;
28
30
  /** 补充映射命中的条数,用于日志 */
29
31
  mappedCount = 0;
@@ -75,7 +77,7 @@ class BangumiDataIndex {
75
77
  const withBroadcast = this.entries.filter((entry) => entry.broadcastMs !== null).length;
76
78
  const withId = this.entries.filter((entry) => entry.bangumiId !== null).length;
77
79
  const translated = this.entries.filter((entry) => entry.zhHans).length;
78
- this.logger.info('[index] bangumi-data 已载入 %d 部作品 (来源: %s)', this.entries.length, this.sourceLabel);
80
+ this.logger.info('[index] bangumi-data 已载入 %d 部作品(来源: %s)', this.entries.length, this.sourceLabel);
79
81
  (0, utils_1.debugLog)('[index] 载入耗时 %dms | 带周期时刻 %d | 带 Bangumi 条目 ID %d | 带简中译名 %d', Date.now() - started, withBroadcast, withId, translated);
80
82
  })();
81
83
  // 载入失败时清掉缓存,下一次调用可以重试,而不是一直拿到同一个被拒绝的 Promise
@@ -91,11 +93,10 @@ class BangumiDataIndex {
91
93
  * 9 月 26 日首播的 10 月番因此留在 10 月季。
92
94
  */
93
95
  seasonWindow(year, month) {
94
- // 起点在上一个月的 20 日之后:1 月季从去年 12/21 开始
95
- const startMs = month === 1 ? Date.UTC(year - 1, 11, 21) : Date.UTC(year, month - 2, 21);
96
- // 终点在本季末月的 20 日之后。注意 `Date.UTC` 的月份下标比自然月小 1:
97
- // 起始月 M 的末月是 M+2,对应下标 M+1(10 月季用 11 表示 12 月)
98
- const endMs = month === 10 ? Date.UTC(year, 11, 21) : Date.UTC(year, month + 1, 21);
96
+ // `Date.UTC` 的月份允许越界并自动换算:1 月季的 `month - 2 = -1` 就是上一年的 12 月,
97
+ // 10 月季的 `month + 1 = 11` 就是 12 月。因此首尾两端都不需要单独判断月份
98
+ const startMs = Date.UTC(year, month - 2, 21);
99
+ const endMs = Date.UTC(year, month + 1, 21);
99
100
  return { startMs, endMs };
100
101
  }
101
102
  /** 取某个季度的清单;`month` 为该季起始月(1 / 4 / 7 / 10),窗口见 `seasonWindow` */
@@ -111,7 +112,7 @@ class BangumiDataIndex {
111
112
  return false;
112
113
  return entry.beginMs >= startMs && entry.beginMs < endMs;
113
114
  });
114
- (0, utils_1.debugLog)('[index] 季度 %d-%d: %d 部 (仅 TV / WEB,首播 %s ~ %s)', year, month, list.length, new Date(startMs).toISOString().slice(0, 10), new Date(endMs - 86400000).toISOString().slice(0, 10));
115
+ (0, utils_1.debugLog)('[index] 季度 %d-%d: %d 部(仅 TV / WEB,首播 %s ~ %s)', year, month, list.length, new Date(startMs).toISOString().slice(0, 10), new Date(endMs - 86400000).toISOString().slice(0, 10));
115
116
  return list;
116
117
  }
117
118
  /**
@@ -156,12 +157,11 @@ class BangumiDataIndex {
156
157
  return this.entryBySubject.get(id) ?? null;
157
158
  }
158
159
  /**
159
- * 按关键词检索索引层。打分规则(高到低):完整标题完全相等(折叠后)→ 标题以关键词开头 →
160
- * 标题包含关键词 → 关键词的每个字符都按顺序出现在标题里(子序列,用于容忍译名与原文的
161
- * 字序差异)。除完全相等外,越短的标题得分越高。
162
- */
163
- /**
164
- * 按标题检索,按相关度降序返回。
160
+ * 按关键词检索索引层。
161
+ *
162
+ * 打分规则(高到低):完整标题完全相等(折叠后)→ 标题以关键词开头 → 标题包含关键词 →
163
+ * 关键词的每个字符都按顺序出现在标题里(子序列,用于容忍译名与原文的字序差异)。
164
+ * 除完全相等外,越短的标题得分越高。
165
165
  *
166
166
  * @param limit 返回条数上限,`<= 0` 表示不限制
167
167
  */
@@ -171,12 +171,9 @@ class BangumiDataIndex {
171
171
  return [];
172
172
  const chars = [...folded];
173
173
  const scored = [];
174
- for (const entry of this.entries) {
174
+ for (const { entry, names } of this.searchables()) {
175
175
  let best = 0;
176
- for (const name of this.namesOf(entry)) {
177
- const value = (0, fold_1.foldTitle)(name);
178
- if (!value)
179
- continue;
176
+ for (const value of names) {
180
177
  let score = 0;
181
178
  if (value === folded)
182
179
  score = 1000;
@@ -203,9 +200,26 @@ class BangumiDataIndex {
203
200
  namesOf(entry) {
204
201
  return [entry.title, entry.zhHans, entry.zhHant, entry.en].filter(Boolean);
205
202
  }
203
+ /**
204
+ * 检索用的「折叠名」列表,首次检索时构建一次。
205
+ *
206
+ * `foldTitle` 要做字符映射、NFKC、大小写与两条 Unicode 正则,八个字的名字约 10µs;
207
+ * 全库两万多个名字每个字都重算一遍是 130ms 级——而索引在进程内不变,折叠结果也不变。
208
+ */
209
+ searchables() {
210
+ if (!this.searchable) {
211
+ this.searchable = this.entries.map((entry) => ({
212
+ entry,
213
+ names: this.namesOf(entry).map(fold_1.foldTitle).filter(Boolean),
214
+ }));
215
+ }
216
+ return this.searchable;
217
+ }
206
218
  /** 把原始清单展开成索引条目 */
207
219
  apply(items, source) {
208
220
  this.sourceLabel = source;
221
+ // 条目换了,折叠名缓存必须作废
222
+ this.searchable = null;
209
223
  const entries = [];
210
224
  const sourceCount = { top: 0, site: 0, none: 0 };
211
225
  let recurringCount = 0;
package/lib/handlers.d.ts CHANGED
@@ -2,7 +2,7 @@ import { Context, h, Logger, Session } from 'koishi';
2
2
  import type { Config } from './config';
3
3
  import type { DigestService } from './services/digest';
4
4
  import { type InfoService } from './services/info';
5
- import type { Renderer } from './render/image';
5
+ import { type Renderer } from './render/image';
6
6
  /** 指令层依赖集合 */
7
7
  export interface HandlerContext {
8
8
  ctx: Context;
package/lib/handlers.js CHANGED
@@ -19,6 +19,7 @@ const koishi_1 = require("koishi");
19
19
  const constants_1 = require("./constants");
20
20
  const info_1 = require("./services/info");
21
21
  const dispatch_1 = require("./services/dispatch");
22
+ const image_1 = require("./render/image");
22
23
  const text_1 = require("./render/text");
23
24
  const time_1 = require("./utils/time");
24
25
  const utils_1 = require("./utils");
@@ -91,27 +92,20 @@ function platformOf(session) {
91
92
  /** 构建一次推送内容并回复当前会话 */
92
93
  async function replyDigest(deps, session, digest) {
93
94
  const { config, renderer, logger } = deps;
95
+ // 内容已经拿到手,评分过期的条目趁出图这段时间在后台补,不占用这次指令的等待
96
+ deps.digest.localizer.kickScoreRefresh();
94
97
  if (!digest.groups.length) {
95
98
  await session.send([digest.header, digest.summary].filter(Boolean).join('\n'));
96
99
  return;
97
100
  }
98
101
  if (config.output.format === 'image') {
99
102
  try {
100
- const buffer = await renderer.render({
101
- kind: digest.kind,
102
- now: digest.generatedAt,
103
- header: digest.header,
104
- summary: digest.summary,
105
- groups: digest.groups,
106
- seasonLabel: digest.seasonLabel,
107
- rangeLabel: digest.rangeLabel ?? '',
108
- footer: (0, constants_1.getMessages)(config.locale).footer,
109
- });
103
+ const buffer = await renderer.render((0, image_1.toRenderPayload)(digest, (0, constants_1.getMessages)(config.locale)));
110
104
  await session.send(koishi_1.h.image(buffer, 'image/jpeg'));
111
105
  return;
112
106
  }
113
107
  catch (error) {
114
- logger.warn('[command] %s', constants_1.RESPONSES.renderFailed, error);
108
+ logger.warn('[command] 出图失败,改用文本发送:', error);
115
109
  }
116
110
  }
117
111
  const content = await (0, dispatch_1.sendTextDigest)(session.bot, session.guildId ?? '', config, digest);
@@ -152,7 +146,8 @@ async function commandSeason(deps, session, target, offsetOption) {
152
146
  const parsed = parseSeasonTarget(target, digest.timeZone);
153
147
  if (parsed.error)
154
148
  return parsed.error;
155
- const offset = offsetOption ?? parsed.offset;
149
+ // `-o` 走 Koishi 的 number 域,`1.5` 也能进来;季度偏移必须是整数,否则会算出「2.5 月新番」
150
+ const offset = offsetOption === undefined ? parsed.offset : Math.trunc(offsetOption);
156
151
  if (offset !== undefined && (offset > SEASON_FUTURE_LIMIT || offset < -40)) {
157
152
  return offset > 0
158
153
  ? `最多往后查 ${SEASON_FUTURE_LIMIT} 季——更远的季度还没公布。`
@@ -558,7 +553,8 @@ async function togglePush(deps, session, kindText, enabled) {
558
553
  if (raw && !kind)
559
554
  return constants_1.RESPONSES.invalidKind(kindText ?? '');
560
555
  try {
561
- // 只在首次为本群建记录时写入默认开关,避免 upsert 把已有的开关重置回默认值
556
+ // 首次为本群建记录时,四种推送一律先关:`anime.on weekly` 只该开本周表,
557
+ // 若默认给 `true`,新群里执行一次 `anime.off weekly` 反而会静默打开另外三种
562
558
  const existing = await digest.getChannel(platformOf(session), channelOf(session));
563
559
  if (!existing) {
564
560
  await digest.ensureChannel({
@@ -566,10 +562,10 @@ async function togglePush(deps, session, kindText, enabled) {
566
562
  channelId: channelOf(session),
567
563
  guildId: session.guildId,
568
564
  botId: session.selfId,
569
- dailyEnabled: true,
570
- nightEnabled: true,
571
- weeklyEnabled: true,
572
- seasonEnabled: true,
565
+ dailyEnabled: false,
566
+ nightEnabled: false,
567
+ weeklyEnabled: false,
568
+ seasonEnabled: false,
573
569
  enabled: true,
574
570
  });
575
571
  }
@@ -600,7 +596,7 @@ async function commandStatus(deps, session) {
600
596
  platform: platformOf(session),
601
597
  channelId: channelOf(session),
602
598
  });
603
- const date = (0, time_1.today)(digest.timeZone);
599
+ const dayStart = (0, time_1.getDayStartMs)((0, time_1.today)(digest.timeZone), digest.timeZone);
604
600
  const formatText = config.output.format === 'image'
605
601
  ? `图片 (${config.output.layout === 'list' ? '卡片列表' : '时间表'})`
606
602
  : `纯文本${config.output.useForward ? ' (合并转发)' : ''}`;
@@ -614,7 +610,8 @@ async function commandStatus(deps, session) {
614
610
  `开关状态: 每日 ${channel?.dailyEnabled ? '开' : '关'} / 深夜档 ${channel?.nightEnabled ? '开' : '关'} / 本周 ${channel?.weeklyEnabled ? '开' : '关'} / 本季 ${channel?.seasonEnabled ? '开' : '关'}`,
615
611
  `订阅番剧: ${subscriptions.length} 部${config.subscription.maxPerChannel > 0 ? ` (上限 ${config.subscription.maxPerChannel})` : ''}`,
616
612
  `开播提醒: ${config.push.updateReminderEnabled ? `开,提前 ${config.push.updateLookaheadMinutes} 分钟` : '关'}`,
617
- `今日推送记录: ${dispatches.filter((row) => row.digestKind !== 'sub' ? row.dedupeKey.endsWith(date) : true).length} 条`,
613
+ // 按记录时间统计:`sub`(开播提醒)的去重键里带的是时刻而不是日期,不能靠键判断
614
+ `今日推送记录: ${dispatches.filter((row) => row.dispatchedAt >= dayStart && row.dispatchedAt < dayStart + 86400000).length} 条`,
618
615
  ].join('\n');
619
616
  }
620
617
  catch (error) {
package/lib/index.js CHANGED
@@ -91,9 +91,8 @@ function apply(ctx, rawConfig) {
91
91
  }, { autoInc: true });
92
92
  // 调试日志开关:打开后输出 API 请求、索引命中与条目匹配细节
93
93
  (0, utils_1.setupDebug)(config.advanced.debug, logger);
94
- if (config.advanced.debug) {
95
- logger.info('[debug] 调试日志已开启,将输出请求 URL、索引命中与条目匹配细节');
96
- }
94
+ if (config.advanced.debug)
95
+ logger.info('[lifecycle] 调试日志已开启,logger 级别已提到 debug');
97
96
  // 出网客户端:Bangumi 接口与图床在国内无法直连,代理在这里统一生效
98
97
  const http = new network_1.HttpClient(ctx, logger, config.advanced.retries);
99
98
  // bangumi-data 索引层:作品清单、中文译名、Bangumi 条目 ID 与周期时刻都来自这里
@@ -111,16 +110,7 @@ function apply(ctx, rawConfig) {
111
110
  const airingReady = airing.ensure();
112
111
  // 免去「未处理的 Promise 拒绝」告警,真实错误在 ready 与构建路径里各自上报
113
112
  Promise.all([indexReady, localizeReady, airingReady]).catch(() => undefined);
114
- const dispatcher = new dispatch_1.Dispatcher(ctx, config, logger, (payload) => renderer.render({
115
- kind: payload.kind,
116
- now: payload.generatedAt,
117
- header: payload.header,
118
- summary: payload.summary,
119
- groups: payload.groups,
120
- seasonLabel: payload.seasonLabel,
121
- rangeLabel: payload.rangeLabel ?? '',
122
- footer: (0, constants_1.getMessages)(config.locale).footer,
123
- }));
113
+ const dispatcher = new dispatch_1.Dispatcher(ctx, config, logger, (payload) => renderer.render((0, image_1.toRenderPayload)(payload, (0, constants_1.getMessages)(config.locale))));
124
114
  const scheduler = new scheduler_1.Scheduler(ctx, config, logger, digest, dispatcher, guard, async () => {
125
115
  await digest.localizer.save();
126
116
  // 顺手把已完结的订阅清掉;`pruneFinishedSubscriptions` 自己按天去重
@@ -144,11 +134,10 @@ function apply(ctx, rawConfig) {
144
134
  else
145
135
  logger.info('[lifecycle] 所有定时推送均已关闭,调度未启动');
146
136
  logger.info('[lifecycle] %s 已启用 (时区: %s,推送形态: %s,本地化: %s,索引: %s)', exports.name, config.timeZone, config.output.format, config.localize.enabled ? '开' : '关', index.ready ? `${index.size} 部 (${index.source})` : '不可用');
147
- logger.info('[lifecycle] 时间表内的时刻均按 %s 显示 (UTC%s)', config.timeZone, formatOffset(config.timeZone));
137
+ logger.info('[lifecycle] 时刻按 %s 显示(UTC%s)', config.timeZone, formatOffset(config.timeZone));
148
138
  }
149
139
  catch (error) {
150
- logger.error('[lifecycle] 插件初始化失败,定时推送将不可用:', error);
151
- logger.error('[lifecycle] 请检查日志并报告问题到: https://github.com/YuzuharaYuka/koishi-plugin-anime-schedule/issues');
140
+ logger.error('[lifecycle] 插件初始化失败,定时推送将不可用(问题反馈见 readme)。原因:', error);
152
141
  }
153
142
  });
154
143
  ctx.on('dispose', async () => {
@@ -215,7 +204,9 @@ function registerCommands(ctx, deps) {
215
204
  .action(async ({ session, options }, target) => {
216
205
  if (!session)
217
206
  return;
218
- await (0, handlers_1.commandSeason)(deps, session, target, options?.offset);
207
+ // 必须 return:参数非法时 `commandSeason` 只返回错误文案而自己不发送,
208
+ // 吞掉返回值就会表现成「机器人没反应」
209
+ return (0, handlers_1.commandSeason)(deps, session, target, options?.offset);
219
210
  });
220
211
  cmd.subcommand('.search <keyword:text>', '搜索番剧作品')
221
212
  .alias('番剧搜索', '搜索新番', '番剧查找')
@@ -9,7 +9,13 @@ export declare class CoverStore {
9
9
  private cacheDays;
10
10
  /** 出网客户端(含代理) */
11
11
  private http;
12
+ /**
13
+ * 内存缓存。值只能是解码成功的画布——下载或解码失败的结果**不入缓存**,
14
+ * 否则一次瞬时超时会让这张封面在进程重启前一直显示占位块。
15
+ */
12
16
  private readonly memory;
17
+ /** `memory` 中画布占用的字节数,用于淘汰 */
18
+ private memoryBytes;
13
19
  private readonly inflight;
14
20
  private readonly cacheRoot;
15
21
  private cacheReady;
@@ -25,6 +31,13 @@ export declare class CoverStore {
25
31
  load(items: CoverRequest[], limit: number): Promise<Map<string, Canvas | null>>;
26
32
  /** 单个封面:内存缓存 → 磁盘缓存 → 网络 */
27
33
  private loadOne;
34
+ /**
35
+ * 记入内存缓存,超出总量上限时淘汰最久未使用的。
36
+ *
37
+ * 按字节而不是按张数:封面尺寸差距很大(150px 的缩略图与 800px 的原图差 20 倍),
38
+ * 按张数算会让上限形同虚设。
39
+ */
40
+ private remember;
28
41
  private fetchAndDecode;
29
42
  private download;
30
43
  private filePath;