koishi-plugin-anime-schedule 1.0.4 → 1.0.5

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.d.ts CHANGED
@@ -35,7 +35,10 @@ export interface Config {
35
35
  localize: {
36
36
  enabled: boolean;
37
37
  titleStyle: TitleStyle;
38
+ /** 标题与封面的缓存天数 */
38
39
  cacheTtlDays: number;
40
+ /** 评分与评价人数的缓存小时数 */
41
+ scoreTtlHours: number;
39
42
  /** 播出状态(是否已完结)的缓存有效期,单位小时 */
40
43
  airingTtlHours: number;
41
44
  };
package/lib/config.js CHANGED
@@ -111,9 +111,14 @@ exports.Config = koishi_1.Schema.intersect([
111
111
  ])
112
112
  .description('标题展示方式')
113
113
  .default('localized'),
114
+ scoreTtlHours: koishi_1.Schema.number()
115
+ .min(1).step(1)
116
+ .description('评分与评价人数的缓存小时数。新番开播头几周评分天天在变,'
117
+ + '因此单独给一个比标题封面短得多的保质期')
118
+ .default(constants_1.DEFAULT_SCORE_TTL_HOURS),
114
119
  cacheTtlDays: koishi_1.Schema.number()
115
120
  .min(1).step(1)
116
- .description('标题、封面与评分的缓存天数')
121
+ .description('标题与封面的缓存天数')
117
122
  .default(30),
118
123
  airingTtlHours: koishi_1.Schema.number()
119
124
  .min(1).step(1)
@@ -204,6 +209,7 @@ function normalizeConfig(config) {
204
209
  localize: {
205
210
  ...config.localize,
206
211
  cacheTtlDays: int(config.localize?.cacheTtlDays, 30, 1),
212
+ scoreTtlHours: int(config.localize?.scoreTtlHours, constants_1.DEFAULT_SCORE_TTL_HOURS, 1),
207
213
  airingTtlHours: int(config.localize?.airingTtlHours, 6, 1),
208
214
  },
209
215
  render: {
@@ -64,6 +64,13 @@ export declare const LOCALIZE_CACHE_FILE = "localize-cache.json";
64
64
  * v6:新增 `hasEpisodeTimes`。逐集档期不再是每条必取,需要靠它区分
65
65
  * 「没取过逐集档期」与「取过但没有集数日期」。
66
66
  */
67
+ /**
68
+ * 评分与评价人数的缓存小时数。
69
+ *
70
+ * 比标题封面的 cacheTtlDays 短得多:新番开播头几周评分天天在变,样本量从个位数
71
+ * 涨到几百,用 30 天缓存会一直显示「只有十几个人投票」时的噪声值。
72
+ */
73
+ export declare const DEFAULT_SCORE_TTL_HOURS = 24;
67
74
  export declare const LOCALIZE_CACHE_VERSION = 6;
68
75
  /** 本地化缓存最大条目数,超出后按写入时间淘汰最旧的一半 */
69
76
  export declare const LOCALIZE_CACHE_MAX_ENTRIES = 5000;
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.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_CONCURRENCY = exports.EPISODE_QUERY_LIMIT = exports.LOCALIZE_CACHE_MAX_ENTRIES = exports.LOCALIZE_CACHE_VERSION = 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_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.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_CONCURRENCY = exports.EPISODE_QUERY_LIMIT = exports.LOCALIZE_CACHE_MAX_ENTRIES = exports.LOCALIZE_CACHE_VERSION = 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_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 主体 */
@@ -80,6 +80,13 @@ exports.LOCALIZE_CACHE_FILE = 'localize-cache.json';
80
80
  * v6:新增 `hasEpisodeTimes`。逐集档期不再是每条必取,需要靠它区分
81
81
  * 「没取过逐集档期」与「取过但没有集数日期」。
82
82
  */
83
+ /**
84
+ * 评分与评价人数的缓存小时数。
85
+ *
86
+ * 比标题封面的 cacheTtlDays 短得多:新番开播头几周评分天天在变,样本量从个位数
87
+ * 涨到几百,用 30 天缓存会一直显示「只有十几个人投票」时的噪声值。
88
+ */
89
+ exports.DEFAULT_SCORE_TTL_HOURS = 24;
83
90
  exports.LOCALIZE_CACHE_VERSION = 6;
84
91
  /** 本地化缓存最大条目数,超出后按写入时间淘汰最旧的一半 */
85
92
  exports.LOCALIZE_CACHE_MAX_ENTRIES = 5000;
@@ -780,7 +780,9 @@ class DigestService {
780
780
  // 取不到(无 aniListId、没查到状态、或那条没给图)时回退 Bangumi。
781
781
  coverUrl: aniList?.coverUrl || (entry.bangumiId !== null ? (subject?.coverUrl ?? '') : ''),
782
782
  scoreValue,
783
- score: scoreValue !== null ? scoreValue.toFixed(1) : '',
783
+ // 两位小数:`score` 由 `rating.count` 算出的算术平均得来(见 `LocalizeCacheEntry.score`),
784
+ // 比 Bangumi 自己给的一位小数更有区分度;四舍五入到一位后与网站显示一致
785
+ score: scoreValue !== null ? scoreValue.toFixed(2) : '',
784
786
  firstYear: firstDate ? Number(firstDate.slice(0, 4)) : null,
785
787
  firstMonth: firstDate ? Number(firstDate.slice(5, 7)) : null,
786
788
  subscribed: entry.bangumiId !== null && subscribed.has(`bgm:${entry.bangumiId}`),
@@ -37,6 +37,19 @@ export declare class Localizer {
37
37
  private scheduleFlush;
38
38
  /** 主动落盘,插件卸载与调度结束后调用 */
39
39
  save(): Promise<void>;
40
+ /**
41
+ * 这一条缓存是否还够用。
42
+ *
43
+ * 拆成「标题封面」与「评分」两个保质期,因为两者变化速度差很多:
44
+ *
45
+ * - **标题与封面**几乎不变,用 `cacheTtlDays`(默认 30 天);
46
+ * - **评分**在新番开播头几周天天在变(样本量从个位数涨到几百),用 `scoreTtlHours`
47
+ * (默认 24 小时)。早先两者共用 30 天,导致「只有 1 个人投票」时的 7.0 被当成
48
+ * 评分显示整整一个月,而网站早已是 6.1。
49
+ *
50
+ * @param wantEpisodeTimes 调用方这次是否需要逐集档期。需要时,缺档期的条目即使标题
51
+ * 还新鲜也得重取一次;不需要时(本季表、检索这类纯展示路径)就不为它白跑请求。
52
+ */
40
53
  private isFresh;
41
54
  /**
42
55
  * 批量取回条目详情,返回「条目 ID → 详情」。
@@ -21,6 +21,42 @@ function resolveEpisodeTotal(subject) {
21
21
  return eps;
22
22
  return null;
23
23
  }
24
+ /**
25
+ * 算评分,保留两位小数。
26
+ *
27
+ * **不用 `rating.score`**:Bangumi 只把它保留一位小数(实测 60 个条目里带两位小数的为 0),
28
+ * 直接拿来显示,第二位永远是 0,没有信息量。API 同时给了 `rating.count`
29
+ * (1~10 各分数段的人数),据此算**算术平均**即可拿到真实精度:
30
+ *
31
+ * ```
32
+ * 至高之力 score=6.1 count 平均=6.0833
33
+ * 药屋第三季 score=7.3 count 平均=7.3231
34
+ * 药屋第一季 score=7.5 count 平均=7.4764
35
+ * ```
36
+ *
37
+ * 与网站显示的关系:四舍五入到一位后与 `score` 一致(`6.0833 → 6.1`、`7.3231 → 7.3`),
38
+ * 因此不会出现「插件和网站对不上」的观感,只是多给了两位真实精度。
39
+ *
40
+ * `count` 缺失(老条目或没投票)时退回 `score`;两者都没有则返回 null。
41
+ */
42
+ function computeScore(rating) {
43
+ const count = rating?.count;
44
+ if (count && typeof count === 'object') {
45
+ let sum = 0;
46
+ let votes = 0;
47
+ for (const [key, value] of Object.entries(count)) {
48
+ const score = Number(key);
49
+ const people = Number(value);
50
+ if (!Number.isFinite(score) || !Number.isFinite(people) || people <= 0)
51
+ continue;
52
+ sum += score * people;
53
+ votes += people;
54
+ }
55
+ if (votes > 0)
56
+ return Math.round((sum / votes) * 100) / 100;
57
+ }
58
+ return typeof rating?.score === 'number' ? rating.score : null;
59
+ }
24
60
  /**
25
61
  * 兼容不同的 http 适配器:Koishi 的 http 服务返回响应体本身,
26
62
  * 而直接使用 axios 的调用方会拿到完整的响应对象。
@@ -135,14 +171,31 @@ class Localizer {
135
171
  }
136
172
  await this.flush();
137
173
  }
138
- isFresh(entry) {
139
- // 「不存在」也是有效结果:同一季内不必反复去问一个不会出现的条目
140
- if (entry.missing)
174
+ /**
175
+ * 这一条缓存是否还够用。
176
+ *
177
+ * 拆成「标题封面」与「评分」两个保质期,因为两者变化速度差很多:
178
+ *
179
+ * - **标题与封面**几乎不变,用 `cacheTtlDays`(默认 30 天);
180
+ * - **评分**在新番开播头几周天天在变(样本量从个位数涨到几百),用 `scoreTtlHours`
181
+ * (默认 24 小时)。早先两者共用 30 天,导致「只有 1 个人投票」时的 7.0 被当成
182
+ * 评分显示整整一个月,而网站早已是 6.1。
183
+ *
184
+ * @param wantEpisodeTimes 调用方这次是否需要逐集档期。需要时,缺档期的条目即使标题
185
+ * 还新鲜也得重取一次;不需要时(本季表、检索这类纯展示路径)就不为它白跑请求。
186
+ */
187
+ isFresh(entry, wantEpisodeTimes) {
188
+ if (entry.missing) {
189
+ // 「不存在」也是有效结果:同一季内不必反复去问一个不会出现的条目
141
190
  return Date.now() - entry.cachedAt < this.config.localize.cacheTtlDays * 24 * 60 * 60 * 1000;
142
- // 取过条目详情、却没取逐集档期(本季表与检索这两条路径会这样),
143
- // 而完结判定需要逐集档期时,必须重新取一次而不是当作新鲜数据用。
191
+ }
192
+ // 取过条目详情、却没取逐集档期,而这次需要档期 → 必须重取。
144
193
  // 旧版缓存没有 hasEpisodeTimes 字段,按「取过」处理。
145
- if (entry.hasEpisodeTimes === false)
194
+ if (wantEpisodeTimes && entry.hasEpisodeTimes === false)
195
+ return false;
196
+ // 评分单独算保质期:到期就重取详情,但重取时仍按调用方的需要决定要不要连档期一起取
197
+ const scoreAt = entry.scoreCachedAt ?? entry.cachedAt;
198
+ if (Date.now() - scoreAt >= this.config.localize.scoreTtlHours * 60 * 60 * 1000)
146
199
  return false;
147
200
  return Date.now() - entry.cachedAt < this.config.localize.cacheTtlDays * 24 * 60 * 60 * 1000;
148
201
  }
@@ -162,6 +215,8 @@ class Localizer {
162
215
  return result;
163
216
  await this.load();
164
217
  const wanted = [];
218
+ /** 这次要重取的条目里,缓存中已有的那一份;用于判断是否还需要补逐集档期 */
219
+ const previous = new Map();
165
220
  const seen = new Set();
166
221
  for (const id of ids) {
167
222
  if (!Number.isFinite(id) || seen.has(id))
@@ -174,16 +229,24 @@ class Localizer {
174
229
  }
175
230
  // 过期条目也先返回,避免一次请求失败就让整张表掉封面
176
231
  result.set(id, cached);
177
- if (!this.isFresh(cached))
232
+ if (!this.isFresh(cached, wantEpisodeTimes)) {
178
233
  wanted.push(id);
234
+ previous.set(id, cached);
235
+ }
179
236
  }
180
237
  if (!wanted.length)
181
238
  return result;
182
239
  const started = Date.now();
183
240
  const looked = await (0, utils_1.mapLimit)(wanted, constants_1.LOCALIZE_CONCURRENCY, async (id) => {
241
+ const stale = previous.get(id);
242
+ // 逐集档期只在「调用方要」且「有正规集数」时才值得请求:用它拿首末集日期。
243
+ //
244
+ // 已经取过档期的条目**不重复取**——评分每天刷新,而档期是另一笔请求,
245
+ // 跟着一起重发会让请求量凭空翻倍(周表 77 部就是多 77 个请求)。
246
+ // 只有「没取过」或「这次确实缺它」才补。
247
+ const needsTimes = wantEpisodeTimes && !(stale && stale.hasEpisodeTimes !== false);
184
248
  const subject = await this.fetchSubject(id);
185
- // 逐集档期只在「调用方要」且「有正规集数」时才值得请求:用它拿首末集日期
186
- const times = wantEpisodeTimes && subject && resolveEpisodeTotal(subject)
249
+ const times = needsTimes && subject && resolveEpisodeTotal(subject)
187
250
  ? await this.fetchEpisodeTimes(id)
188
251
  : null;
189
252
  return { id, subject, times };
@@ -193,18 +256,24 @@ class Localizer {
193
256
  if (!subject)
194
257
  continue;
195
258
  ok++;
259
+ const stale = previous.get(id);
260
+ // 这次没重新取档期(评分刷新)时,沿用上一次的结果,别把它当「没有档期」
261
+ const hasTimes = times !== null || stale?.hasEpisodeTimes === true;
196
262
  const entry = {
197
263
  subjectId: subject.id,
198
264
  title: subject.name_cn?.trim() || subject.name,
199
265
  coverUrl: subject.images?.large ?? subject.images?.common ?? '',
200
- score: typeof subject.rating?.score === 'number' ? subject.rating.score : null,
266
+ score: computeScore(subject.rating),
201
267
  ratingTotal: typeof subject.rating?.total === 'number' ? subject.rating.total : null,
202
268
  totalEpisodes: resolveEpisodeTotal(subject),
203
- hasEpisodeTimes: times !== null,
204
- firstAirMs: times?.first ?? null,
205
- lastAirMs: times?.last ?? null,
269
+ hasEpisodeTimes: hasTimes,
270
+ firstAirMs: times?.first ?? stale?.firstAirMs ?? null,
271
+ lastAirMs: times?.last ?? stale?.lastAirMs ?? null,
206
272
  airDate: subject.date ?? null,
207
- cachedAt: Date.now(),
273
+ // `cachedAt` 只在真的取全(含档期)时推进,否则标题封面的 30 天保质期会被
274
+ // 每天一次的评分刷新无限续期,等于永远不校验
275
+ cachedAt: times !== null || !stale ? Date.now() : stale.cachedAt,
276
+ scoreCachedAt: Date.now(),
208
277
  };
209
278
  this.memory.set(id, entry);
210
279
  this.scheduleFlush();
package/lib/types.d.ts CHANGED
@@ -70,8 +70,26 @@ export interface LocalizeCacheEntry {
70
70
  /** 中文名,缺失时回落为日文原名 */
71
71
  title: string;
72
72
  coverUrl: string;
73
+ /**
74
+ * 评分,两位小数。
75
+ *
76
+ * 由 `rating.count`(各分数段人数)算出的**算术平均**,而不是 Bangumi 直接给的
77
+ * `rating.score`——后者只保留一位小数(实测 60 个条目里带两位小数的为 0),
78
+ * 自己算才有第二位。四舍五入到一位后与网站显示一致,不会造成「对不上」的观感。
79
+ * `rating.count` 缺失时退回 `rating.score`。
80
+ */
73
81
  score: number | null;
74
82
  ratingTotal: number | null;
83
+ /**
84
+ * 评分与 `ratingTotal` 的取回时刻。
85
+ *
86
+ * 与 `cachedAt` 分开,因为两者保质期差很多:标题与封面几乎不变(`cacheTtlDays`,
87
+ * 默认 30 天),而**新番的评分在开播头几周会剧烈变化**(样本量从个位数涨到几百),
88
+ * 用 30 天缓存会一直显示「只有十几个人投票」时的噪声值。见 `localize.scoreTtlHours`。
89
+ *
90
+ * 旧缓存没有这个字段,按 `cachedAt` 处理(即可能立刻被刷新一次)。
91
+ */
92
+ scoreCachedAt?: number;
75
93
  /**
76
94
  * Bangumi 给出的总集数,仅作缓存留档。
77
95
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-anime-schedule",
3
3
  "description": "番剧更新表与订阅提醒:每日 / 深夜档 / 本周 / 本季四张表,支持单部订阅",
4
- "version": "1.0.4",
4
+ "version": "1.0.5",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -56,6 +56,7 @@ npm i koishi-plugin-anime-schedule
56
56
  | `output.format` | `image` | `image` / `text` |
57
57
  | `output.layout` | `table` | 图片排版:`table` / `list` |
58
58
  | `localize.titleStyle` | `localized` | `localized` 仅中文 / `both` 中文(日文原名)/ `original` 仅日文原名 |
59
+ | `localize.scoreTtlHours` | `24` | 评分与评价人数的缓存小时数。新番开播头几周评分天天在变,因此单独给一个较短的保质期 |
59
60
  | `render.jpegQuality` | `100` | 图片编码质量,调低可减小体积 |
60
61
  | `advanced.debug` | `false` | 输出调试日志 |
61
62