koishi-plugin-anime-schedule 1.0.9 → 1.1.0

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
@@ -165,7 +165,7 @@ exports.Config = koishi_1.Schema.intersect([
165
165
  requestTimeout: koishi_1.Schema.number()
166
166
  .min(1)
167
167
  .description('单次请求超时(秒)')
168
- .default(20),
168
+ .default(constants_1.LOCALIZE_TIMEOUT_SECONDS),
169
169
  retries: koishi_1.Schema.number()
170
170
  .min(0)
171
171
  .description('请求失败重试次数')
@@ -238,7 +238,7 @@ function normalizeConfig(config) {
238
238
  },
239
239
  advanced: {
240
240
  ...config.advanced,
241
- requestTimeout: int(config.advanced?.requestTimeout, 20, 1),
241
+ requestTimeout: int(config.advanced?.requestTimeout, constants_1.LOCALIZE_TIMEOUT_SECONDS, 1),
242
242
  retries: int(config.advanced?.retries, 2),
243
243
  },
244
244
  };
@@ -19,6 +19,12 @@ export declare const ANILIST_BATCH_SIZE = 50;
19
19
  * 相当于最多 50 次/分钟,既不会触发突发限流,也远低于上限。
20
20
  */
21
21
  export declare const ANILIST_MIN_INTERVAL_MS = 1200;
22
+ /**
23
+ * 收到 429 / 403 后的冷却时长。
24
+ *
25
+ * 429 是超限封禁、403 是 API 被临时停用,两种情况都沿用已有数据一段时间再试。
26
+ */
27
+ export declare const ANILIST_COOLDOWN_MS: number;
22
28
  /**
23
29
  * bangumi-data 索引。
24
30
  *
@@ -85,10 +91,18 @@ export declare const LOCALIZE_CACHE_MAX_ENTRIES = 5000;
85
91
  export declare const EPISODE_QUERY_LIMIT = 100;
86
92
  /**
87
93
  * 并发检索 Bangumi 的条数上限。
88
- * Bangumi 没有公开的严格限流,但一次本季表可能有上百部作品,
89
- * 4 路并发已能把原本串行的等待时间压到 1/4,同时不至于触发风控。
94
+ *
95
+ * Bangumi 没有公开的严格限流,但一次本季表可能有上百部作品。取 6 路:实测 4 路时周表
96
+ * 取详情要 50 秒上下,6 路约省三分之一,仍远低于会让站点限流的密度。
97
+ */
98
+ export declare const LOCALIZE_CONCURRENCY = 6;
99
+ /**
100
+ * 单次元数据请求的超时(秒),`advanced.requestTimeout` 的默认值。
101
+ *
102
+ * Bangumi 的条目接口是小体积 JSON,正常几百毫秒返回。等满 20 秒基本等价于连接已死,
103
+ * 而一条超时会把整次构建拖住这么久,所以默认压到 10 秒;嫌紧可以自行调大。
90
104
  */
91
- export declare const LOCALIZE_CONCURRENCY = 4;
105
+ export declare const LOCALIZE_TIMEOUT_SECONDS = 10;
92
106
  /** 并发拉取封面时的并发上限 */
93
107
  export declare const COVER_CONCURRENCY = 8;
94
108
  /** 封面下载超时与体积上限,避免个别大图拖垮整次渲染 */
@@ -101,15 +115,6 @@ export declare const COVER_CACHE_MAX_FILES = 1200;
101
115
  /** 图片渲染用的字体候选,按平台常见路径排列,找不到时回退到运行环境默认字体 */
102
116
  export declare const FONT_CANDIDATES: string[];
103
117
  /** 加粗字体候选,找不到时由绘制层用描边模拟粗体 */
104
- /**
105
- * 兜底字体族候选。
106
- *
107
- * 与 `FONT_CANDIDATES` 的区别:那些是**主字体**(选一个用),这些是**补充**
108
- * (全部注册,缺字形时按顺序回退)。`@napi-rs/canvas` 只在"字体族不存在"时回退,
109
- * 族存在但缺字形就直接画豆腐块,所以补充族必须真的注册进来才有效。
110
- * 名字要与 `render/fonts.ts` 的 `FALLBACK_FAMILIES` 一致。
111
- */
112
- export declare const FALLBACK_FONT_CANDIDATES: string[];
113
118
  export declare const BOLD_FONT_CANDIDATES: string[];
114
119
  /**
115
120
  * JPEG 编码质量。
@@ -308,12 +313,8 @@ export interface Messages {
308
313
  footer: string;
309
314
  /** 周历表表头的日期区间,例如 `9月21日 - 9月27日`;两端已由 formatDayLabel 本地化 */
310
315
  rangeLabel: (start: string, end: string) => string;
311
- /** 今天的日期列角标 */
312
- todayBadge: string;
313
316
  /** 已在订阅列表里的作品,条目上的气泡 */
314
317
  subscribedBadge: string;
315
- /** 日期列超出上限时的提示 */
316
- moreItems: (count: number) => string;
317
318
  /** 详情卡页脚的数据更新时间 */
318
319
  infoUpdated: (moment: string) => string;
319
320
  /** 连载状态 */
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.FALLBACK_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.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_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.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;
4
4
  exports.buildUserAgent = buildUserAgent;
5
5
  exports.getMessages = getMessages;
6
6
  /** 插件名,同时作为日志前缀与 User-Agent 主体 */
@@ -23,6 +23,12 @@ exports.ANILIST_BATCH_SIZE = 50;
23
23
  * 相当于最多 50 次/分钟,既不会触发突发限流,也远低于上限。
24
24
  */
25
25
  exports.ANILIST_MIN_INTERVAL_MS = 1200;
26
+ /**
27
+ * 收到 429 / 403 后的冷却时长。
28
+ *
29
+ * 429 是超限封禁、403 是 API 被临时停用,两种情况都沿用已有数据一段时间再试。
30
+ */
31
+ exports.ANILIST_COOLDOWN_MS = 5 * 60 * 1000;
26
32
  /**
27
33
  * bangumi-data 索引。
28
34
  *
@@ -101,10 +107,18 @@ exports.LOCALIZE_CACHE_MAX_ENTRIES = 5000;
101
107
  exports.EPISODE_QUERY_LIMIT = 100;
102
108
  /**
103
109
  * 并发检索 Bangumi 的条数上限。
104
- * Bangumi 没有公开的严格限流,但一次本季表可能有上百部作品,
105
- * 4 路并发已能把原本串行的等待时间压到 1/4,同时不至于触发风控。
110
+ *
111
+ * Bangumi 没有公开的严格限流,但一次本季表可能有上百部作品。取 6 路:实测 4 路时周表
112
+ * 取详情要 50 秒上下,6 路约省三分之一,仍远低于会让站点限流的密度。
113
+ */
114
+ exports.LOCALIZE_CONCURRENCY = 6;
115
+ /**
116
+ * 单次元数据请求的超时(秒),`advanced.requestTimeout` 的默认值。
117
+ *
118
+ * Bangumi 的条目接口是小体积 JSON,正常几百毫秒返回。等满 20 秒基本等价于连接已死,
119
+ * 而一条超时会把整次构建拖住这么久,所以默认压到 10 秒;嫌紧可以自行调大。
106
120
  */
107
- exports.LOCALIZE_CONCURRENCY = 4;
121
+ exports.LOCALIZE_TIMEOUT_SECONDS = 10;
108
122
  /** 并发拉取封面时的并发上限 */
109
123
  exports.COVER_CONCURRENCY = 8;
110
124
  /** 封面下载超时与体积上限,避免个别大图拖垮整次渲染 */
@@ -131,20 +145,6 @@ exports.FONT_CANDIDATES = [
131
145
  '/usr/share/fonts/truetype/arphic/uming.ttc',
132
146
  ];
133
147
  /** 加粗字体候选,找不到时由绘制层用描边模拟粗体 */
134
- /**
135
- * 兜底字体族候选。
136
- *
137
- * 与 `FONT_CANDIDATES` 的区别:那些是**主字体**(选一个用),这些是**补充**
138
- * (全部注册,缺字形时按顺序回退)。`@napi-rs/canvas` 只在"字体族不存在"时回退,
139
- * 族存在但缺字形就直接画豆腐块,所以补充族必须真的注册进来才有效。
140
- * 名字要与 `render/fonts.ts` 的 `FALLBACK_FAMILIES` 一致。
141
- */
142
- exports.FALLBACK_FONT_CANDIDATES = [
143
- 'C:/Windows/Fonts/YuGothR.ttc',
144
- 'C:/Windows/Fonts/msgothic.ttc',
145
- 'C:/Windows/Fonts/simsun.ttc',
146
- 'C:/Windows/Fonts/NotoSansSC-VF.ttf',
147
- ];
148
148
  exports.BOLD_FONT_CANDIDATES = [
149
149
  'C:/Windows/Fonts/msyhbd.ttc',
150
150
  'C:/Windows/Fonts/Dengb.ttf',
@@ -342,9 +342,7 @@ const ZH_CN = {
342
342
  truncated: (shown, total) => `仅显示前 ${shown} 部,其余 ${total - shown} 部请使用指令按需查询。`,
343
343
  footer: '数据来源: bangumi-data / Bangumi',
344
344
  rangeLabel: (start, end) => `${start} - ${end}`,
345
- todayBadge: '今天',
346
345
  subscribedBadge: '已订阅',
347
- moreItems: (count) => `还有 ${count} 部`,
348
346
  infoUpdated: (moment) => `数据更新于 ${moment}`,
349
347
  infoAiring: '连载中',
350
348
  infoFinished: '已完结',
@@ -387,9 +385,7 @@ const JA_JP = {
387
385
  truncated: (shown, total) => `先頭 ${shown} 作品のみ表示しています (残り ${total - shown} 作品)。`,
388
386
  footer: 'データ提供: bangumi-data / Bangumi',
389
387
  rangeLabel: (start, end) => `${start} 〜 ${end}`,
390
- todayBadge: '今日',
391
388
  subscribedBadge: '登録済み',
392
- moreItems: (count) => `他 ${count} 作品`,
393
389
  infoUpdated: (moment) => `データ更新 ${moment}`,
394
390
  infoAiring: '放送中',
395
391
  infoFinished: '完結',
@@ -432,9 +428,7 @@ const EN_US = {
432
428
  truncated: (shown, total) => `Showing first ${shown} of ${total} titles.`,
433
429
  footer: 'Data: bangumi-data / Bangumi',
434
430
  rangeLabel: (start, end) => `${start} - ${end}`,
435
- todayBadge: 'Today',
436
431
  subscribedBadge: 'Subscribed',
437
- moreItems: (count) => `+${count} more`,
438
432
  infoUpdated: (moment) => `Updated ${moment}`,
439
433
  infoAiring: 'Airing',
440
434
  infoFinished: 'Finished',
@@ -1,5 +1,6 @@
1
1
  import type { Context, Logger } from 'koishi';
2
2
  import type { BangumiDataItem } from './bangumi-data-source';
3
+ import { HttpClient } from '../services/network';
3
4
  /** 一次载入的结果 */
4
5
  export interface LoadedData {
5
6
  items: BangumiDataItem[];
@@ -10,7 +11,7 @@ export declare class BangumiDataLoader {
10
11
  private ctx;
11
12
  private logger;
12
13
  private readonly http;
13
- constructor(ctx: Context, logger: Logger);
14
+ constructor(ctx: Context, logger: Logger, http: HttpClient);
14
15
  private get cachePath();
15
16
  /** 依次尝试:npm 依赖包 → 磁盘缓存 → CDN 镜像 */
16
17
  load(): Promise<LoadedData | null>;
@@ -10,7 +10,6 @@ const fs_1 = require("fs");
10
10
  const path_1 = require("path");
11
11
  const constants_1 = require("../constants");
12
12
  const utils_1 = require("../utils");
13
- const network_1 = require("../services/network");
14
13
  /** 只在缓存里保留用得到的字段,避免把整个包写一遍 */
15
14
  const CACHED_FIELDS = [
16
15
  'title', 'titleTranslate', 'type', 'officialSite', 'begin', 'broadcast', 'end', 'sites',
@@ -19,10 +18,10 @@ class BangumiDataLoader {
19
18
  ctx;
20
19
  logger;
21
20
  http;
22
- constructor(ctx, logger) {
21
+ constructor(ctx, logger, http) {
23
22
  this.ctx = ctx;
24
23
  this.logger = logger;
25
- this.http = new network_1.HttpClient(ctx, logger);
24
+ this.http = http;
26
25
  }
27
26
  get cachePath() {
28
27
  return (0, path_1.resolve)(this.ctx.baseDir, ...constants_1.DATA_DIR, constants_1.BANGUMI_DATA_FILE);
@@ -4,6 +4,7 @@
4
4
  * `bangumi-data-loader.ts`,本文件只负责建立索引并提供查询。
5
5
  */
6
6
  import type { Context, Logger } from 'koishi';
7
+ import type { HttpClient } from '../services/network';
7
8
  import type { BroadcastSource } from './bangumi-data-source';
8
9
  /** 索引层对外暴露的一条作品 */
9
10
  export interface IndexEntry {
@@ -63,7 +64,7 @@ export declare class BangumiDataIndex {
63
64
  private readonly loader;
64
65
  /** 补充映射命中的条数,用于日志 */
65
66
  private mappedCount;
66
- constructor(ctx: Context, logger: Logger);
67
+ constructor(ctx: Context, logger: Logger, http: HttpClient);
67
68
  get ready(): boolean;
68
69
  get size(): number;
69
70
  /**
@@ -114,4 +115,3 @@ export declare class BangumiDataIndex {
114
115
  /** 把原始清单展开成索引条目 */
115
116
  private apply;
116
117
  }
117
- export type { BangumiDataItem } from './bangumi-data-source';
@@ -27,9 +27,9 @@ class BangumiDataIndex {
27
27
  loader;
28
28
  /** 补充映射命中的条数,用于日志 */
29
29
  mappedCount = 0;
30
- constructor(ctx, logger) {
30
+ constructor(ctx, logger, http) {
31
31
  this.logger = logger;
32
- this.loader = new bangumi_data_loader_1.BangumiDataLoader(ctx, logger);
32
+ this.loader = new bangumi_data_loader_1.BangumiDataLoader(ctx, logger, http);
33
33
  }
34
34
  get ready() {
35
35
  return this.entries.length > 0;
package/lib/handlers.js CHANGED
@@ -344,8 +344,6 @@ async function sendInfoDetail(deps, session, bgmId) {
344
344
  }
345
345
  /** 出图,失败时退回文本;两者都不可用时返回 `null` */
346
346
  async function renderInfoNode(deps, detail) {
347
- if (!detail)
348
- return null;
349
347
  if (deps.config.output.format !== 'image')
350
348
  return koishi_1.h.text((0, text_1.renderInfoText)(detail, deps.config));
351
349
  try {
package/lib/index.js CHANGED
@@ -97,10 +97,10 @@ function apply(ctx, rawConfig) {
97
97
  // 出网客户端:Bangumi 接口与图床在国内无法直连,代理在这里统一生效
98
98
  const http = new network_1.HttpClient(ctx, logger, config.advanced.retries);
99
99
  // bangumi-data 索引层:作品清单、中文译名、Bangumi 条目 ID 与周期时刻都来自这里
100
- const index = new bangumi_data_1.BangumiDataIndex(ctx, logger);
100
+ const index = new bangumi_data_1.BangumiDataIndex(ctx, logger, http);
101
101
  // AniList 播出状态:判断「这部番是否还在播」的权威依据
102
- const airing = new airing_1.AiringStatusService(ctx, logger);
103
- const digest = new digest_1.DigestService(ctx, config, logger, index, airing);
102
+ const airing = new airing_1.AiringStatusService(ctx, logger, http);
103
+ const digest = new digest_1.DigestService(ctx, config, logger, index, http, airing);
104
104
  const renderer = new image_1.Renderer(ctx, config, logger, http);
105
105
  const guard = new dispatch_1.DispatchGuard(ctx, logger);
106
106
  // 单部作品详情:只服务 `anime.info` 的交互查询,不参与推送
@@ -1,6 +1,4 @@
1
- import { IMAGE_THEME } from '../constants';
2
1
  import type { SKRSContext2D } from '@napi-rs/canvas';
3
- export type Theme = typeof IMAGE_THEME;
4
2
  /** 主题别名,渲染层统一用 `theme.xxx` 取色 */
5
3
  export declare const THEME: {
6
4
  background: string;
@@ -61,7 +59,4 @@ export declare function drawContainImage(ctx: SKRSContext2D, image: {
61
59
  background?: string;
62
60
  border?: boolean;
63
61
  shadow?: boolean;
64
- }): {
65
- width: number;
66
- height: number;
67
- };
62
+ }): void;
@@ -130,7 +130,7 @@ function drawContainImage(ctx, image, x, y, width, height, options = {}) {
130
130
  fillRoundRect(ctx, x, y, width, height, radius, options.background ?? exports.THEME.badge);
131
131
  }
132
132
  if (!image.width || !image.height)
133
- return { width: 0, height: 0 };
133
+ return;
134
134
  const scale = Math.min(width / image.width, height / image.height);
135
135
  const drawWidth = Math.max(1, Math.floor(image.width * scale));
136
136
  const drawHeight = Math.max(1, Math.floor(image.height * scale));
@@ -149,5 +149,4 @@ function drawContainImage(ctx, image, x, y, width, height, options = {}) {
149
149
  if (options.border !== false) {
150
150
  strokeRoundRect(ctx, x + 0.5, y + 0.5, width - 1, height - 1, radius, exports.THEME.cardBorder);
151
151
  }
152
- return { width: drawWidth, height: drawHeight };
153
152
  }
@@ -4,7 +4,6 @@ import type { CoverRequest } from '../types';
4
4
  import { HttpClient } from '../services/network';
5
5
  /** 封面加载器:内存 → 磁盘 → 网络三级缓存 + 并发下载,同一张封面一次推送只下载一次 */
6
6
  export declare class CoverStore {
7
- private ctx;
8
7
  private logger;
9
8
  private concurrency;
10
9
  private cacheDays;
@@ -29,6 +28,8 @@ export declare class CoverStore {
29
28
  private fetchAndDecode;
30
29
  private download;
31
30
  private filePath;
31
+ /** 该封面是否命中磁盘缓存(仅用于统计,不参与加载流程) */
32
+ private isOnDisk;
32
33
  private readDiskCache;
33
34
  private writeDiskCache;
34
35
  /** 清理过期的封面缓存,插件启动时调用一次即可 */
@@ -9,6 +9,13 @@ const constants_1 = require("../constants");
9
9
  const utils_1 = require("../utils");
10
10
  /** 等待一张封面解码完成的最长时间 */
11
11
  const DECODE_WAIT_MS = 5000;
12
+ /**
13
+ * 单张封面的总时间预算(含多候选地址)。
14
+ *
15
+ * 未命中缓存的封面要按候选地址逐个试,每个都等满 8s 的话,一张图就能拖住整批;
16
+ * 封面缺失只是退化成占位块,不值得为它耗更多时间。
17
+ */
18
+ const COVER_TOTAL_BUDGET_MS = 12000;
12
19
  /** 同一张封面的备用地址:Bangumi 图床按尺寸分目录(`/pic/cover/l|c|m|s/`),个别条目会缺某个尺寸,需逐个回退 */
13
20
  function coverUrlCandidates(url) {
14
21
  const matched = /^(https?:\/\/[^/]+\/pic\/cover\/)([a-z]+)(\/.*)$/i.exec(url);
@@ -73,7 +80,6 @@ function waitForLoad(image) {
73
80
  }
74
81
  /** 封面加载器:内存 → 磁盘 → 网络三级缓存 + 并发下载,同一张封面一次推送只下载一次 */
75
82
  class CoverStore {
76
- ctx;
77
83
  logger;
78
84
  concurrency;
79
85
  cacheDays;
@@ -86,7 +92,6 @@ class CoverStore {
86
92
  constructor(ctx, logger, concurrency, cacheDays,
87
93
  /** 出网客户端(含代理) */
88
94
  http) {
89
- this.ctx = ctx;
90
95
  this.logger = logger;
91
96
  this.concurrency = concurrency;
92
97
  this.cacheDays = cacheDays;
@@ -119,11 +124,16 @@ class CoverStore {
119
124
  const started = Date.now();
120
125
  let downloaded = 0;
121
126
  let fromDisk = 0;
127
+ let fromMemory = 0;
122
128
  const entries = await (0, utils_1.mapLimit)(urls, this.concurrency, async (url) => {
129
+ // 内存命中多半来自「取详情时的预热」;磁盘命中说明缓存生效;两者都不是才是真下载。
130
+ // 三个数分开统计,才能从日志判断预热有没有起作用。
123
131
  const hadMemory = this.memory.has(url);
124
132
  const canvas = await this.loadOne(url);
125
133
  if (canvas) {
126
134
  if (hadMemory)
135
+ fromMemory++;
136
+ else if (await this.isOnDisk(url))
127
137
  fromDisk++;
128
138
  else
129
139
  downloaded++;
@@ -136,7 +146,7 @@ class CoverStore {
136
146
  this.logger.debug('[render] 封面就绪 %d/%d 张 (%dms)', ok, urls.length, Date.now() - started);
137
147
  // 封面缺失会影响版面,失败比例高时给出 info 提示便于定位
138
148
  if (ok < urls.length) {
139
- this.logger.info('[render] 封面 %d/%d 张可用(内存或磁盘命中 %d,新下载 %d,失败 %d)', ok, urls.length, fromDisk, downloaded, urls.length - ok);
149
+ this.logger.info('[render] 封面 %d/%d 张可用(预热命中 %d,磁盘命中 %d,新下载 %d,失败 %d)', ok, urls.length, fromMemory, fromDisk, downloaded, urls.length - ok);
140
150
  }
141
151
  return result;
142
152
  }
@@ -168,9 +178,16 @@ class CoverStore {
168
178
  // 磁盘上的文件损坏时删掉,重新下载
169
179
  await fs_1.promises.rm(this.filePath(url), { force: true }).catch(() => undefined);
170
180
  }
171
- // 逐个候选地址尝试:图床偶尔缺某个尺寸的图,只试一个地址整张封面就变成占位块了
181
+ // 逐个候选地址尝试:图床偶尔缺某个尺寸的图,只试一个地址整张封面就变成占位块了。
182
+ // 总耗时受 `COVER_TOTAL_BUDGET_MS` 约束,避免多个候选叠加成十几秒。
183
+ const deadline = Date.now() + COVER_TOTAL_BUDGET_MS;
172
184
  for (const candidate of coverUrlCandidates(url)) {
173
- const buffer = await this.download(candidate);
185
+ const remaining = deadline - Date.now();
186
+ if (remaining <= 0) {
187
+ this.logger.debug('[render] 封面超出时间预算,使用占位块: %s', url);
188
+ break;
189
+ }
190
+ const buffer = await this.download(candidate, Math.min(constants_1.COVER_TIMEOUT_MS, remaining));
174
191
  if (!buffer)
175
192
  continue;
176
193
  const canvas = await decodeToCanvas(buffer);
@@ -187,11 +204,13 @@ class CoverStore {
187
204
  this.logger.debug('[render] 封面全部候选地址都失败: %s', url);
188
205
  return null;
189
206
  }
190
- async download(url) {
207
+ async download(url, timeout = constants_1.COVER_TIMEOUT_MS) {
191
208
  try {
192
209
  const buffer = toBuffer(await this.http.get(url, {
193
- timeout: constants_1.COVER_TIMEOUT_MS,
210
+ timeout,
194
211
  responseType: 'arraybuffer',
212
+ // 封面已有候选地址回退,重试同一地址只会把单次超时叠成数倍
213
+ retries: 0,
195
214
  }));
196
215
  if (!buffer) {
197
216
  this.logger.debug('[render] 封面响应不是二进制数据,使用占位块: %s', url);
@@ -214,6 +233,18 @@ class CoverStore {
214
233
  const hash = (0, crypto_1.createHash)('sha1').update(url).digest('hex');
215
234
  return (0, path_1.resolve)(this.cacheRoot, hash.slice(0, 2), `${hash}.img`);
216
235
  }
236
+ /** 该封面是否命中磁盘缓存(仅用于统计,不参与加载流程) */
237
+ async isOnDisk(url) {
238
+ if (!this.enabled)
239
+ return false;
240
+ try {
241
+ await fs_1.promises.access(this.filePath(url));
242
+ return true;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
217
248
  async readDiskCache(url) {
218
249
  if (!this.enabled)
219
250
  return null;
@@ -153,8 +153,6 @@ class Renderer {
153
153
  /** 选画布宽度:各版面在常量里各有固定宽度,保证同一版面每次出图尺寸一致 */
154
154
  canvasWidth(payload) {
155
155
  const L = constants_1.IMAGE_LAYOUT;
156
- if (payload.kind === 'season' && this.config.output.layout !== 'list')
157
- return L.seasonWidth;
158
156
  if (payload.kind === 'weekly')
159
157
  return L.weekWidth;
160
158
  if (this.config.output.layout === 'list')
@@ -178,7 +176,6 @@ class Renderer {
178
176
  const width = L.timelineWidth;
179
177
  // 只有一组时不画日期节点
180
178
  const showDayHeader = groups.length > 1;
181
- const showNow = payload.kind !== 'weekly';
182
179
  const spineX = L.padding + L.timelineLabelWidth;
183
180
  const elements = [];
184
181
  const dayGap = showDayHeader ? L.timelineDayHeaderHeight : 0;
@@ -192,7 +189,7 @@ class Renderer {
192
189
  }
193
190
  for (const item of group.items) {
194
191
  // 「现在」插在第一条未播出的条目之前
195
- if (showNow && !nowPlaced && item.atMs > payload.now) {
192
+ if (!nowPlaced && item.atMs > payload.now) {
196
193
  nowPlaced = true;
197
194
  elements.push({ kind: 'now', centre: cursor + NOW_MARKER_HEIGHT / 2 });
198
195
  cursor += NOW_MARKER_HEIGHT + separatorGap;
@@ -202,7 +199,7 @@ class Renderer {
202
199
  }
203
200
  }
204
201
  // 全部已播时「现在」落在末尾,与末条留出间距
205
- if (showNow && !nowPlaced) {
202
+ if (!nowPlaced) {
206
203
  cursor += separatorGap;
207
204
  elements.push({ kind: 'now', centre: cursor + NOW_MARKER_HEIGHT / 2 });
208
205
  cursor += NOW_MARKER_HEIGHT;
@@ -529,10 +526,6 @@ class Renderer {
529
526
  cursor += ctx.measureText(item.time).width + 8;
530
527
  ctx.font = (0, fonts_1.font)(fonts_1.FONT_SIZE.weekTime);
531
528
  ctx.fillText((0, canvas_2.fitText)(ctx, episode, Math.max(20, x + width - 8 - cursor)), cursor, y + 18);
532
- cursor += ctx.measureText(episode).width + 7;
533
- }
534
- else {
535
- cursor += ctx.measureText(item.time).width + 7;
536
529
  }
537
530
  // 第二行起:标题(最多两行)
538
531
  ctx.font = (0, fonts_1.font)(fonts_1.FONT_SIZE.weekTitle, true);
@@ -40,7 +40,6 @@ const L = {
40
40
  summaryGap: 14,
41
41
  /** 评分图表:10 柱 × (柱宽 + 间距),另留 10 个刻度数字的位置 */
42
42
  scoreChartWidth: 240,
43
- footerGapColumns: 24,
44
43
  scoreChartHeight: 60,
45
44
  scoreAxisHeight: 18,
46
45
  scoreBarWidth: 18,
@@ -87,7 +86,7 @@ function renderInfoCard(card) {
87
86
  const progressColumnWidth = L.epPerRow * L.epMinWidth + (L.epPerRow - 1) * L.epGap;
88
87
  const footerTop = L.padding + headerHeight + summaryHeight + L.sectionGap;
89
88
  const bandTop = footerTop + L.sectionTitleSize + L.sectionTitleGap;
90
- const band = bandMetrics(bandTop, info.totalEpisodes, progressColumnWidth, scoreColumnWidth);
89
+ const band = bandMetrics(bandTop, info.totalEpisodes, progressColumnWidth);
91
90
  // 两列顶部对齐、各自决定高度,取较高者作为底块高度
92
91
  const footerHeight = Math.max(band.progressHeight, band.scoreHeight);
93
92
  const height = bandTop + footerHeight + L.footerGap + L.padding + L.footerHeight;
@@ -166,7 +165,7 @@ function renderInfoCard(card) {
166
165
  // 底块:两列独立布局,顶部对齐
167
166
  const titleLine = bandTop - L.sectionTitleSize - L.sectionTitleGap;
168
167
  drawProgressTile(ctx, info, messages, left, progressColumnWidth, titleLine, bandTop, band.grid);
169
- drawScoreTile(ctx, info, messages, scoreColumnX, scoreColumnWidth, titleLine, band.baseline);
168
+ drawScoreTile(ctx, info, messages, scoreColumnX, titleLine, band.baseline);
170
169
  // ── 页脚:左边条目 ID(供 `anime.sub` / `anime.info` 直接抄),右边数据更新时间 ──
171
170
  ctx.font = (0, fonts_1.font)(fonts_1.FONT_SIZE.footer);
172
171
  ctx.textBaseline = 'top';
@@ -260,8 +259,7 @@ function gridMetrics(total, width, perRow) {
260
259
  return { cols, rows, height: rows * L.epHeight + (rows - 1) * L.epGap };
261
260
  }
262
261
  /** 底部两列的几何:两列顶部对齐、各自决定高度互不拉伸;柱区不超过左列高度,避免柱子顶到上一行胶囊 */
263
- function bandMetrics(bandTop, total, progressWidth, scoreWidth) {
264
- void scoreWidth;
262
+ function bandMetrics(bandTop, total, progressWidth) {
265
263
  const grid = total !== null && total > 0 && total <= L.epGridMax
266
264
  ? gridMetrics(total, progressWidth, L.epPerRow)
267
265
  : null;
@@ -287,7 +285,6 @@ function drawProgressTile(ctx, info, messages, x, columnWidth, lineY, top, grid)
287
285
  ctx.textAlign = 'left';
288
286
  ctx.textBaseline = 'top';
289
287
  ctx.fillText(value, x + titleWidth, lineY + 1);
290
- void columnWidth;
291
288
  if (!grid) {
292
289
  // 长期连载番或话数未知:只给一行说明,不铺胶囊,否则几百话会画出上百行
293
290
  ctx.font = (0, fonts_1.font)(L.bodySize);
@@ -331,7 +328,7 @@ function drawProgressTile(ctx, info, messages, x, columnWidth, lineY, top, grid)
331
328
  *
332
329
  * @param baseline 柱子底边,刻度画在它下面
333
330
  */
334
- function drawScoreTile(ctx, info, messages, x, columnWidth, titleY, baseline) {
331
+ function drawScoreTile(ctx, info, messages, x, titleY, baseline) {
335
332
  drawSectionTitle(ctx, messages.infoScoreBoard, x, titleY);
336
333
  const count = info.scoreCount;
337
334
  if (!count) {
@@ -12,6 +12,7 @@
12
12
  import type { Context, Logger } from 'koishi';
13
13
  import type { AiringState } from '../types';
14
14
  import type { MatchTarget } from '../data/anilist-match';
15
+ import { HttpClient } from './network';
15
16
  /**
16
17
  * 播出数据查询器。
17
18
  * 只负责「按 ID 取数据并缓存」;「据此决定怎么排期」由 `DigestService` 负责。
@@ -19,6 +20,7 @@ import type { MatchTarget } from '../data/anilist-match';
19
20
  export declare class AiringStatusService {
20
21
  private ctx;
21
22
  private logger;
23
+ private http;
22
24
  /** 进程内缓存;按 AniList 条款不落盘 */
23
25
  private readonly memory;
24
26
  /**
@@ -28,20 +30,17 @@ export declare class AiringStatusService {
28
30
  private readonly idByTitle;
29
31
  /** 已经用掉的标题搜索次数,见 `TITLE_SEARCH_BUDGET` */
30
32
  private titleSearches;
31
- private readonly http;
32
33
  /** 冷却截止时刻:收到 429 后到这时刻之前不再发请求 */
33
34
  private cooldownUntil;
34
35
  /** 上一次请求结束的时刻,用于保证最小间隔 */
35
36
  private lastRequestAt;
36
37
  /** 请求串行化,避免并发触发突发限流 */
37
38
  private queue;
38
- constructor(ctx: Context, logger: Logger);
39
+ constructor(ctx: Context, logger: Logger, http: HttpClient);
39
40
  /** 当前是否处于限流冷却中 */
40
41
  get coolingDown(): boolean;
41
42
  /** 无需预热(缓存只在内存里),保留此方法让调用方写法统一 */
42
43
  ensure(): Promise<void>;
43
- /** 条款要求不落盘,因此没有持久化动作;保留此方法兼容调用方 */
44
- save(): Promise<void>;
45
44
  private isFresh;
46
45
  /**
47
46
  * 按标题给缺失 `aniList` ID 的条目补一个 ID:补上就用 AniList 的逐話時刻排期,搜不到返回
@@ -4,7 +4,6 @@ exports.AiringStatusService = void 0;
4
4
  const constants_1 = require("../constants");
5
5
  const utils_1 = require("../utils");
6
6
  const anilist_match_1 = require("../data/anilist-match");
7
- const network_1 = require("./network");
8
7
  const QUERY = `query ($ids: [Int]) {
9
8
  Page(page: 1, perPage: 50) {
10
9
  media(id_in: $ids, type: ANIME) {
@@ -49,6 +48,7 @@ const TITLE_SEARCH_BUDGET = 24;
49
48
  class AiringStatusService {
50
49
  ctx;
51
50
  logger;
51
+ http;
52
52
  /** 进程内缓存;按 AniList 条款不落盘 */
53
53
  memory = new Map();
54
54
  /**
@@ -58,17 +58,16 @@ class AiringStatusService {
58
58
  idByTitle = new Map();
59
59
  /** 已经用掉的标题搜索次数,见 `TITLE_SEARCH_BUDGET` */
60
60
  titleSearches = 0;
61
- http;
62
61
  /** 冷却截止时刻:收到 429 后到这时刻之前不再发请求 */
63
62
  cooldownUntil = 0;
64
63
  /** 上一次请求结束的时刻,用于保证最小间隔 */
65
64
  lastRequestAt = 0;
66
65
  /** 请求串行化,避免并发触发突发限流 */
67
66
  queue = Promise.resolve();
68
- constructor(ctx, logger) {
67
+ constructor(ctx, logger, http) {
69
68
  this.ctx = ctx;
70
69
  this.logger = logger;
71
- this.http = new network_1.HttpClient(ctx, logger);
70
+ this.http = http;
72
71
  }
73
72
  /** 当前是否处于限流冷却中 */
74
73
  get coolingDown() {
@@ -78,8 +77,6 @@ class AiringStatusService {
78
77
  ensure() {
79
78
  return Promise.resolve();
80
79
  }
81
- /** 条款要求不落盘,因此没有持久化动作;保留此方法兼容调用方 */
82
- async save() { }
83
80
  isFresh(entry, ttlDays) {
84
81
  return Date.now() - entry.cachedAt < ttlDays * 24 * 60 * 60 * 1000;
85
82
  }
@@ -246,11 +243,11 @@ class AiringStatusService {
246
243
  * 服务被停用);`false` 表示普通失败,调用方按「取不到就跳过」处理。
247
244
  */
248
245
  handleRequestError(error) {
249
- const status = error?.response?.status;
246
+ const status = (0, utils_1.errorStatus)(error);
250
247
  if (status === 429 || status === 403) {
251
248
  // 429:超限封禁 1 分钟;403:API 被临时停用。两种情况都进入冷却
252
- this.cooldownUntil = Date.now() + ANILIST_COOLDOWN_MS;
253
- this.logger.warn('[airing] AniList 返回 %d(%s),暂停查询 %d 分钟,期间沿用已有数据', status, status === 429 ? '超出速率限制' : 'API 被临时停用', ANILIST_COOLDOWN_MS / 60000);
249
+ this.cooldownUntil = Date.now() + constants_1.ANILIST_COOLDOWN_MS;
250
+ this.logger.warn('[airing] AniList 返回 %d(%s),暂停查询 %d 分钟,期间沿用已有数据', status, status === 429 ? '超出速率限制' : 'API 被临时停用', constants_1.ANILIST_COOLDOWN_MS / 60000);
254
251
  return true;
255
252
  }
256
253
  this.logger.debug('[airing] AniList 查询失败(本次不判定完结): %s', (0, utils_1.errorMessage)(error));
@@ -276,5 +273,3 @@ class AiringStatusService {
276
273
  }
277
274
  }
278
275
  exports.AiringStatusService = AiringStatusService;
279
- /** 收到 429 / 403 后的冷却时长 */
280
- const ANILIST_COOLDOWN_MS = 5 * 60 * 1000;