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 +9 -5
- package/lib/constants.d.ts +28 -2
- package/lib/constants.js +37 -3
- package/lib/data/bangumi-data.d.ts +14 -6
- package/lib/data/bangumi-data.js +32 -18
- package/lib/handlers.d.ts +1 -1
- package/lib/handlers.js +16 -19
- package/lib/index.js +8 -17
- package/lib/render/covers.d.ts +13 -0
- package/lib/render/covers.js +101 -28
- package/lib/render/fonts.js +2 -2
- package/lib/render/image.d.ts +14 -1
- package/lib/render/image.js +43 -8
- package/lib/render/info.d.ts +0 -4
- package/lib/render/info.js +18 -8
- package/lib/services/airing.d.ts +10 -1
- package/lib/services/airing.js +22 -2
- package/lib/services/digest.d.ts +18 -1
- package/lib/services/digest.js +155 -33
- package/lib/services/dispatch.js +5 -3
- package/lib/services/info.d.ts +7 -0
- package/lib/services/info.js +33 -7
- package/lib/services/localizer.d.ts +19 -4
- package/lib/services/localizer.js +72 -21
- package/lib/services/scheduler.d.ts +2 -0
- package/lib/services/scheduler.js +26 -20
- package/lib/utils/index.d.ts +6 -0
- package/lib/utils/index.js +10 -1
- package/package.json +2 -2
- package/readme.md +1 -1
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(
|
|
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:
|
|
208
|
-
updatePollMinutes:
|
|
211
|
+
updateLookaheadMinutes: lookahead,
|
|
212
|
+
updatePollMinutes: Math.min(poll, lookahead),
|
|
209
213
|
},
|
|
210
214
|
content: {
|
|
211
215
|
maxItems: int(config.content?.maxItems, 0),
|
package/lib/constants.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
40
|
-
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
}
|
package/lib/data/bangumi-data.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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.
|
|
174
|
+
for (const { entry, names } of this.searchables()) {
|
|
175
175
|
let best = 0;
|
|
176
|
-
for (const
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
570
|
-
nightEnabled:
|
|
571
|
-
weeklyEnabled:
|
|
572
|
-
seasonEnabled:
|
|
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
|
|
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
|
-
|
|
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('[
|
|
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]
|
|
137
|
+
logger.info('[lifecycle] 时刻按 %s 显示(UTC%s)', config.timeZone, formatOffset(config.timeZone));
|
|
148
138
|
}
|
|
149
139
|
catch (error) {
|
|
150
|
-
logger.error('[lifecycle]
|
|
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
|
-
|
|
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('番剧搜索', '搜索新番', '番剧查找')
|
package/lib/render/covers.d.ts
CHANGED
|
@@ -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;
|