koishi-plugin-anime-schedule 1.0.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/LICENSE +21 -0
- package/lib/config.d.ts +64 -0
- package/lib/config.js +222 -0
- package/lib/constants.d.ts +270 -0
- package/lib/constants.js +355 -0
- package/lib/data/anilist-match.d.ts +71 -0
- package/lib/data/anilist-match.js +85 -0
- package/lib/data/bangumi-data-loader.d.ts +23 -0
- package/lib/data/bangumi-data-loader.js +110 -0
- package/lib/data/bangumi-data-source.d.ts +73 -0
- package/lib/data/bangumi-data-source.js +79 -0
- package/lib/data/bangumi-data.d.ts +139 -0
- package/lib/data/bangumi-data.js +276 -0
- package/lib/handlers.d.ts +59 -0
- package/lib/handlers.js +515 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +302 -0
- package/lib/render/canvas.d.ts +67 -0
- package/lib/render/canvas.js +128 -0
- package/lib/render/covers.d.ts +42 -0
- package/lib/render/covers.js +304 -0
- package/lib/render/fonts.d.ts +40 -0
- package/lib/render/fonts.js +74 -0
- package/lib/render/image.d.ts +168 -0
- package/lib/render/image.js +746 -0
- package/lib/render/text.d.ts +18 -0
- package/lib/render/text.js +53 -0
- package/lib/schedule/anilist-ids.d.ts +37 -0
- package/lib/schedule/anilist-ids.js +78 -0
- package/lib/services/airing.d.ts +100 -0
- package/lib/services/airing.js +293 -0
- package/lib/services/digest.d.ts +401 -0
- package/lib/services/digest.js +1408 -0
- package/lib/services/dispatch.d.ts +60 -0
- package/lib/services/dispatch.js +163 -0
- package/lib/services/localizer.d.ts +82 -0
- package/lib/services/localizer.js +326 -0
- package/lib/services/network.d.ts +63 -0
- package/lib/services/network.js +84 -0
- package/lib/services/scheduler.d.ts +78 -0
- package/lib/services/scheduler.js +281 -0
- package/lib/types.d.ts +241 -0
- package/lib/types.js +7 -0
- package/lib/utils/fold.d.ts +12 -0
- package/lib/utils/fold.js +62 -0
- package/lib/utils/index.d.ts +36 -0
- package/lib/utils/index.js +139 -0
- package/lib/utils/time.d.ts +44 -0
- package/lib/utils/time.js +176 -0
- package/package.json +46 -0
- package/readme.md +81 -0
package/lib/constants.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/** 插件名,同时作为日志前缀与 User-Agent 主体 */
|
|
2
|
+
export const PLUGIN_NAME = 'anime-schedule';
|
|
3
|
+
/** Bangumi API 主机 */
|
|
4
|
+
export const BANGUMI_API_HOST = 'https://api.bgm.tv';
|
|
5
|
+
/**
|
|
6
|
+
* AniList GraphQL 接口。
|
|
7
|
+
*
|
|
8
|
+
* 用于查询「这部番是否还在播」:它直接给出 `status`(FINISHED / RELEASING)与 `endDate`,
|
|
9
|
+
* 而 bangumi-data 自带 `aniList` 的条目 ID,可以按 ID 精确查询、避开标题匹配歧义。
|
|
10
|
+
*/
|
|
11
|
+
export const ANILIST_API_URL = 'https://graphql.anilist.co';
|
|
12
|
+
/** 单次 GraphQL 请求最多查多少个条目 */
|
|
13
|
+
export const ANILIST_BATCH_SIZE = 50;
|
|
14
|
+
/**
|
|
15
|
+
* 两次 AniList 请求之间的最小间隔(毫秒)。
|
|
16
|
+
*
|
|
17
|
+
* 官方限制 90 次/分钟(实测响应头为 30),另有突发限流。留出 1.2 秒间隔后
|
|
18
|
+
* 相当于最多 50 次/分钟,既不会触发突发限流,也远低于上限。
|
|
19
|
+
*/
|
|
20
|
+
export const ANILIST_MIN_INTERVAL_MS = 1200;
|
|
21
|
+
/**
|
|
22
|
+
* bangumi-data 索引。
|
|
23
|
+
*
|
|
24
|
+
* 数据默认来自 npm 依赖包 `bangumi-data`(`main` 指向 `dist/data.json`),
|
|
25
|
+
* 随插件一起安装、无需联网;下面这些地址只在依赖包缺失时才作为回退。
|
|
26
|
+
* 仓库: https://github.com/bangumi-data/bangumi-data
|
|
27
|
+
*/
|
|
28
|
+
export const BANGUMI_DATA_FALLBACK_URLS = [
|
|
29
|
+
'https://cdn.jsdelivr.net/npm/bangumi-data@latest/dist/data.json',
|
|
30
|
+
'https://unpkg.com/bangumi-data@latest/dist/data.json',
|
|
31
|
+
'https://raw.githubusercontent.com/bangumi-data/bangumi-data/master/dist/data.json',
|
|
32
|
+
];
|
|
33
|
+
export const BANGUMI_DATA_FILE = 'bangumi-data.json';
|
|
34
|
+
/** bangumi-data 回退缓存的目录(相对于 ctx.baseDir),与本地化缓存放在一起 */
|
|
35
|
+
export const DATA_DIR = ['data', 'anime-schedule'];
|
|
36
|
+
/** 插件版本与主页,请求 Bangumi 时用于自报身份 */
|
|
37
|
+
export const PLUGIN_VERSION = '1.0.0';
|
|
38
|
+
export const PLUGIN_HOMEPAGE = 'https://github.com/YuzuharaYuka/koishi-plugin-anime-schedule';
|
|
39
|
+
/**
|
|
40
|
+
* 请求 Bangumi 时使用的 User-Agent。
|
|
41
|
+
* Bangumi 要求客户端自报身份,形如 `AppName/version (+主页)`。
|
|
42
|
+
*/
|
|
43
|
+
export function buildUserAgent() {
|
|
44
|
+
return `${PLUGIN_NAME}/${PLUGIN_VERSION} (+${PLUGIN_HOMEPAGE})`;
|
|
45
|
+
}
|
|
46
|
+
/** 各类推送的展示名,用于日志、合并转发标题与开关回复 */
|
|
47
|
+
export const DIGEST_LABELS = {
|
|
48
|
+
daily: '每日更新表',
|
|
49
|
+
night: '深夜档',
|
|
50
|
+
weekly: '本周更新表',
|
|
51
|
+
season: '本季新番表',
|
|
52
|
+
sub: '番剧订阅',
|
|
53
|
+
};
|
|
54
|
+
/** 卡片列表排版绘制的卡片数上限,避免条目异常多时图片长度失控 */
|
|
55
|
+
export const IMAGE_MAX_CARDS = 200;
|
|
56
|
+
/** 列表排版超过这个数量就折成两栏,避免长图过长 */
|
|
57
|
+
export const IMAGE_LIST_TWO_COLUMN_AT = 26;
|
|
58
|
+
/**
|
|
59
|
+
* 「深夜番」的判定:本地时间 0 点到 6 点算作前一日的深夜档。
|
|
60
|
+
*
|
|
61
|
+
* 与 `DigestService` 的深夜档窗口终点(次日 06:00)保持一致——两处都表示「凌晨这一段的结束时刻」,
|
|
62
|
+
* 改一处时另一处也要跟着改。这里的用途是统计与文案(`finish()` 里的「其中 N 部为深夜档」、
|
|
63
|
+
* 时间轴的 24 小时制续写),不是窗口本身。
|
|
64
|
+
*/
|
|
65
|
+
export const LATE_NIGHT_END_HOUR = 6;
|
|
66
|
+
/** 本地化缓存的落盘文件名 */
|
|
67
|
+
export const LOCALIZE_CACHE_FILE = 'localize-cache.json';
|
|
68
|
+
/**
|
|
69
|
+
* 本地化缓存的结构版本。
|
|
70
|
+
*
|
|
71
|
+
* 条目字段变化时必须递增:版本不匹配会整份重建,避免旧条目缺少新字段
|
|
72
|
+
* (例如判断完结所需的 `lastAirDate`)导致逻辑静默失效。
|
|
73
|
+
*
|
|
74
|
+
* v2:新增 `totalEpisodes` 与 `airDate`;旧缓存缺这两个字段,不丢弃会一直重复请求。
|
|
75
|
+
* v6:新增 `hasEpisodeTimes`。逐集档期不再是每条必取,需要靠它区分
|
|
76
|
+
* 「没取过逐集档期」与「取过但没有集数日期」。
|
|
77
|
+
*/
|
|
78
|
+
export const LOCALIZE_CACHE_VERSION = 6;
|
|
79
|
+
/** 本地化缓存最大条目数,超出后按写入时间淘汰最旧的一半 */
|
|
80
|
+
export const LOCALIZE_CACHE_MAX_ENTRIES = 5000;
|
|
81
|
+
/** 取逐集档期时的单页集数上限;超过这个数的长期番不靠集数判断完结 */
|
|
82
|
+
export const EPISODE_QUERY_LIMIT = 100;
|
|
83
|
+
/**
|
|
84
|
+
* 并发检索 Bangumi 的条数上限。
|
|
85
|
+
* Bangumi 没有公开的严格限流,但一次本季表可能有上百部作品,
|
|
86
|
+
* 4 路并发已能把原本串行的等待时间压到 1/4,同时不至于触发风控。
|
|
87
|
+
*/
|
|
88
|
+
export const LOCALIZE_CONCURRENCY = 4;
|
|
89
|
+
/** 并发拉取封面时的并发上限 */
|
|
90
|
+
export const COVER_CONCURRENCY = 8;
|
|
91
|
+
/** 封面下载超时与体积上限,避免个别大图拖垮整次渲染 */
|
|
92
|
+
export const COVER_TIMEOUT_MS = 8000;
|
|
93
|
+
export const COVER_MAX_BYTES = 4 * 1024 * 1024;
|
|
94
|
+
/** 封面磁盘缓存目录(相对于 ctx.baseDir)与有效期 */
|
|
95
|
+
export const COVER_CACHE_DIR = ['data', 'anime-schedule', 'covers'];
|
|
96
|
+
export const COVER_CACHE_TTL_MS = 21 * 24 * 60 * 60 * 1000;
|
|
97
|
+
export const COVER_CACHE_MAX_FILES = 1200;
|
|
98
|
+
/** 图片渲染用的字体候选,按平台常见路径排列,找不到时回退到运行环境默认字体 */
|
|
99
|
+
export const FONT_CANDIDATES = [
|
|
100
|
+
'C:/Windows/Fonts/msyh.ttc',
|
|
101
|
+
'C:/Windows/Fonts/msyh.ttf',
|
|
102
|
+
'C:/Windows/Fonts/simhei.ttf',
|
|
103
|
+
'C:/Windows/Fonts/Deng.ttf',
|
|
104
|
+
'C:/Windows/Fonts/simsun.ttc',
|
|
105
|
+
'/System/Library/Fonts/PingFang.ttc',
|
|
106
|
+
'/System/Library/Fonts/STHeiti Medium.ttc',
|
|
107
|
+
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
|
108
|
+
'/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf',
|
|
109
|
+
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
|
110
|
+
'/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
|
|
111
|
+
'/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc',
|
|
112
|
+
'/usr/share/fonts/truetype/arphic/uming.ttc',
|
|
113
|
+
];
|
|
114
|
+
/** 加粗字体候选,找不到时由绘制层用描边模拟粗体 */
|
|
115
|
+
export const BOLD_FONT_CANDIDATES = [
|
|
116
|
+
'C:/Windows/Fonts/msyhbd.ttc',
|
|
117
|
+
'C:/Windows/Fonts/Dengb.ttf',
|
|
118
|
+
'C:/Windows/Fonts/simhei.ttf',
|
|
119
|
+
'/System/Library/Fonts/PingFang.ttc',
|
|
120
|
+
'/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc',
|
|
121
|
+
'/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc',
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* JPEG 编码质量。
|
|
125
|
+
*
|
|
126
|
+
* 时间表以文字与细线条为主,JPEG 的块状伪影在这种内容上比照片里明显得多。取 100
|
|
127
|
+
* 是**无损**编码,文字边缘最锐利;体积比 92 大约多三成,但一张周表也就一兆出头,
|
|
128
|
+
* 不值得为省这点流量牺牲清晰度。想压体积可以调低。
|
|
129
|
+
*/
|
|
130
|
+
export const DEFAULT_JPEG_QUALITY = 100;
|
|
131
|
+
/** 注册到 canvas 的字体族名,与 config.render.fontFamily 的默认值保持一致 */
|
|
132
|
+
export const DEFAULT_FONT_FAMILY = 'Anime Schedule Sans';
|
|
133
|
+
/**
|
|
134
|
+
* 图片版式常量。
|
|
135
|
+
*
|
|
136
|
+
* 版式遵循同一套规则,所有版面共享它,避免各处各写一套数字:
|
|
137
|
+
* - **页眉**:左侧强调条 + 标题,其下是副标题,右上角是统计与时区,固定高度 120。
|
|
138
|
+
* - **页脚**:清一色 bottom 对齐,只写数据来源。
|
|
139
|
+
* - **卡片**:内容一律**居左**排列;封面、标题、次要信息的竖直位置固定,
|
|
140
|
+
* 与文字长短无关,因此整张图的每一行都对齐在同样的基线上。
|
|
141
|
+
* - **封面**:统一 `媒体高 × 1.4` 的竖版比例,等比完整显示,不裁切。
|
|
142
|
+
*/
|
|
143
|
+
export const IMAGE_LAYOUT = {
|
|
144
|
+
/** 统一外边距 */
|
|
145
|
+
padding: 24,
|
|
146
|
+
/** 页眉 / 页脚固定高度 */
|
|
147
|
+
headerHeight: 120,
|
|
148
|
+
footerHeight: 52,
|
|
149
|
+
/** 周历表:七天并排的画布宽度 */
|
|
150
|
+
weekWidth: 1680,
|
|
151
|
+
/** 单日时间轴(每日 / 深夜档)的画布宽度 */
|
|
152
|
+
timelineWidth: 900,
|
|
153
|
+
/** 卡片列表(list 排版)的画布宽度 */
|
|
154
|
+
listWidth: 900,
|
|
155
|
+
/** 本季封面网格的画布宽度,要放得下 10 列 */
|
|
156
|
+
seasonWidth: 1680,
|
|
157
|
+
// ---- 通用行卡片 ----
|
|
158
|
+
/** 行卡片高度(每日 / 列表排版)。容纳「标题 + 时刻 + 集数 + ID + 评分」五行 */
|
|
159
|
+
rowHeight: 112,
|
|
160
|
+
/** 行卡片之间的间距 */
|
|
161
|
+
rowGap: 8,
|
|
162
|
+
/** 行卡片圆角与内边距 */
|
|
163
|
+
rowRadius: 12,
|
|
164
|
+
rowPadding: 12,
|
|
165
|
+
/** 行卡片封面高度,宽度按 1 : 1.4 推出 */
|
|
166
|
+
mediaHeight: 82,
|
|
167
|
+
/** 封面与文字之间的间距 */
|
|
168
|
+
mediaGap: 14,
|
|
169
|
+
/** 卡片文字行间距。五行内容靠它撑开节奏,太小会挤成一团 */
|
|
170
|
+
cardLineGap: 6,
|
|
171
|
+
/** 封面圆角 */
|
|
172
|
+
mediaRadius: 6,
|
|
173
|
+
// ---- 单日时间轴 ----
|
|
174
|
+
/** 时间轴轨道相对左边距的位置 */
|
|
175
|
+
timelineSpine: 50,
|
|
176
|
+
/** 轨道圆点半径 */
|
|
177
|
+
timelineDot: 4,
|
|
178
|
+
/** 时刻文字与轨道之间的间距 */
|
|
179
|
+
timelineTimeGap: 12,
|
|
180
|
+
/** 时刻文字与封面之间的间距 */
|
|
181
|
+
timelineCardGap: 20,
|
|
182
|
+
/** 日期节点高度(深夜档的「今晚 / 次日凌晨」) */
|
|
183
|
+
timelineDayHeaderHeight: 56,
|
|
184
|
+
/** 时间轴主线颜色 */
|
|
185
|
+
timelineLine: '#f6d2cc',
|
|
186
|
+
// ---- 周历表 ----
|
|
187
|
+
/** 七日列之间的间距与整张卡的内边距 */
|
|
188
|
+
weekColumnGap: 18,
|
|
189
|
+
weekCardPadding: 16,
|
|
190
|
+
/** 周历表列头高度 */
|
|
191
|
+
weekHeaderHeight: 92,
|
|
192
|
+
/** 周历表条目高度、行距与标题行数 */
|
|
193
|
+
weekRowHeight: 104,
|
|
194
|
+
weekRowGap: 8,
|
|
195
|
+
weekTitleLines: 2,
|
|
196
|
+
weekTitleLineHeight: 18,
|
|
197
|
+
/** 周历表条目内的封面高度(与三行文字齐高,保持 1 : 1.4 比例不再被压扁) */
|
|
198
|
+
weekMediaHeight: 82,
|
|
199
|
+
// ---- 卡片列表 ----
|
|
200
|
+
/** 列表卡片高度(与行卡片一致,两种排版观感统一) */
|
|
201
|
+
cardHeight: 112,
|
|
202
|
+
cardGap: 12,
|
|
203
|
+
/** 两栏之间的间距 */
|
|
204
|
+
cardColumnGap: 14,
|
|
205
|
+
/** 分组标题占用的高度 */
|
|
206
|
+
sectionHeaderHeight: 34,
|
|
207
|
+
// ---- 本季封面网格 ----
|
|
208
|
+
/** 每行的番剧数量 */
|
|
209
|
+
seasonColumns: 10,
|
|
210
|
+
/** 网格列间距与行间距 */
|
|
211
|
+
seasonColumnGap: 20,
|
|
212
|
+
seasonRowGap: 18,
|
|
213
|
+
/** 网格卡片内边距与卡片高度。封面盒高即卡片高减去上下内边距与元信息高度 */
|
|
214
|
+
seasonPadding: 8,
|
|
215
|
+
seasonCardHeight: 292,
|
|
216
|
+
/** 标题行数与行高(固定两行,保证首播时间、ID 与评分在同一基线) */
|
|
217
|
+
seasonTitleLines: 2,
|
|
218
|
+
seasonTitleLineHeight: 19,
|
|
219
|
+
/** 首播时间 / 条目 ID / 评分三行共用的行高 */
|
|
220
|
+
gridMetaLineHeight: 16,
|
|
221
|
+
/** 封面宽高比(1 : 1.4 的竖版,与 Bangumi 原图一致) */
|
|
222
|
+
seasonCoverRatio: 0.72,
|
|
223
|
+
};
|
|
224
|
+
/** 时间表配色,全插件共用一套色板 */
|
|
225
|
+
export const IMAGE_THEME = {
|
|
226
|
+
background: '#f4f5f8',
|
|
227
|
+
surface: '#ffffff',
|
|
228
|
+
cardBorder: '#e6e8ef',
|
|
229
|
+
divider: '#eef0f5',
|
|
230
|
+
/** 强调色:时刻、日期与「今天」高亮 */
|
|
231
|
+
accent: '#f6665f',
|
|
232
|
+
/** 封面的底色(等比完整显示时留白的那一圈) */
|
|
233
|
+
badge: '#eef1f7',
|
|
234
|
+
title: '#1f2330',
|
|
235
|
+
body: '#5a6172',
|
|
236
|
+
muted: '#9aa1b1',
|
|
237
|
+
onAccent: '#ffffff',
|
|
238
|
+
score: '#f0913c',
|
|
239
|
+
/** 卡片投影:略深一点,白色卡片才从浅灰背景上立起来 */
|
|
240
|
+
shadow: 'rgba(31, 35, 48, 0.10)',
|
|
241
|
+
};
|
|
242
|
+
const ZH_CN = {
|
|
243
|
+
weekdayNames: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
|
|
244
|
+
dailyHeader: (date) => `今日番剧更新 (${date})`,
|
|
245
|
+
nightHeader: (date) => `今晚深夜档 (${date})`,
|
|
246
|
+
weeklyHeader: (start, end) => `本周番剧更新 (${start} 至 ${end})`,
|
|
247
|
+
seasonHeader: (label) => `${label} 新番表`,
|
|
248
|
+
subHeader: (count) => `订阅番剧更新 (共 ${count} 部)`,
|
|
249
|
+
emptyDaily: '今天没有符合条件的番剧播出。',
|
|
250
|
+
emptyNight: '今晚没有符合条件的深夜档节目。',
|
|
251
|
+
emptyWeekly: '本周没有符合条件的番剧播出。',
|
|
252
|
+
emptySeason: '本季暂时没有符合条件的番剧。',
|
|
253
|
+
emptySub: '本群还没有订阅任何番剧,使用 `anime.sub <ID>` 添加。',
|
|
254
|
+
noAiringSub: (count) => `本群订阅的 ${count} 部作品今天都没有播出。`,
|
|
255
|
+
daytimeGroup: '今日播出',
|
|
256
|
+
tonightGroup: (date) => `今晚 (${date} 21:00 起)`,
|
|
257
|
+
tomorrowDawnGroup: (date) => `次日凌晨 (${date})`,
|
|
258
|
+
seasonLabel: (year, month) => `${year} 年 ${month} 月新番`,
|
|
259
|
+
countUnit: '部',
|
|
260
|
+
episodeUnit: (count) => `第 ${count} 话`,
|
|
261
|
+
episodeShort: (count) => `#${count}`,
|
|
262
|
+
summaryOf: (total, lateNight) => `共 ${total} 部作品${lateNight ? `,其中 ${lateNight} 部为深夜档` : ''}`,
|
|
263
|
+
truncated: (shown, total) => `仅显示前 ${shown} 部,其余 ${total - shown} 部请使用指令按需查询。`,
|
|
264
|
+
footer: '数据来源: bangumi-data / Bangumi',
|
|
265
|
+
rangeLabel: (start, end) => `${start} - ${end}`,
|
|
266
|
+
todayBadge: '今天',
|
|
267
|
+
subscribedBadge: '已订阅',
|
|
268
|
+
moreItems: (count) => `还有 ${count} 部`,
|
|
269
|
+
};
|
|
270
|
+
const JA_JP = {
|
|
271
|
+
...ZH_CN,
|
|
272
|
+
weekdayNames: ['日曜', '月曜', '火曜', '水曜', '木曜', '金曜', '土曜'],
|
|
273
|
+
dailyHeader: (date) => `本日の番組表 (${date})`,
|
|
274
|
+
nightHeader: (date) => `今夜の深夜枠 (${date})`,
|
|
275
|
+
weeklyHeader: (start, end) => `今週の番組表 (${start} 〜 ${end})`,
|
|
276
|
+
seasonHeader: (label) => `${label} 新作アニメ`,
|
|
277
|
+
subHeader: (count) => `登録作品の更新 (全 ${count} 作品)`,
|
|
278
|
+
emptyDaily: '本日は条件に合う番組がありません。',
|
|
279
|
+
emptyNight: '今夜は条件に合う深夜枠がありません。',
|
|
280
|
+
emptyWeekly: '今週は条件に合う番組がありません。',
|
|
281
|
+
emptySeason: '今期は条件に合う作品がありません。',
|
|
282
|
+
emptySub: 'このチャンネルには登録作品がありません。`anime.sub <ID>` で追加できます。',
|
|
283
|
+
noAiringSub: (count) => `登録中の ${count} 作品は本日放送がありません。`,
|
|
284
|
+
daytimeGroup: '本日の放送',
|
|
285
|
+
tonightGroup: (date) => `今夜 (${date} 21:00 以降)`,
|
|
286
|
+
tomorrowDawnGroup: (date) => `翌日未明 (${date})`,
|
|
287
|
+
seasonLabel: (year, month) => `${year}年${month}月期`,
|
|
288
|
+
countUnit: '作品',
|
|
289
|
+
episodeUnit: (count) => `第${count}話`,
|
|
290
|
+
episodeShort: (count) => `#${count}`,
|
|
291
|
+
summaryOf: (total, lateNight) => `全 ${total} 作品${lateNight ? `、うち深夜枠 ${lateNight} 作品` : ''}`,
|
|
292
|
+
truncated: (shown, total) => `先頭 ${shown} 作品のみ表示しています (残り ${total - shown} 作品)。`,
|
|
293
|
+
footer: 'データ提供: bangumi-data / Bangumi',
|
|
294
|
+
rangeLabel: (start, end) => `${start} 〜 ${end}`,
|
|
295
|
+
todayBadge: '今日',
|
|
296
|
+
subscribedBadge: '登録済み',
|
|
297
|
+
moreItems: (count) => `他 ${count} 作品`,
|
|
298
|
+
};
|
|
299
|
+
const EN_US = {
|
|
300
|
+
...ZH_CN,
|
|
301
|
+
weekdayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
|
302
|
+
dailyHeader: (date) => `Airing today (${date})`,
|
|
303
|
+
nightHeader: (date) => `Late night tonight (${date})`,
|
|
304
|
+
weeklyHeader: (start, end) => `Airing this week (${start} to ${end})`,
|
|
305
|
+
seasonHeader: (label) => `${label} lineup`,
|
|
306
|
+
subHeader: (count) => `Subscribed titles (${count})`,
|
|
307
|
+
emptyDaily: 'No matching programs air today.',
|
|
308
|
+
emptyNight: 'No late-night programs tonight.',
|
|
309
|
+
emptyWeekly: 'No matching programs air this week.',
|
|
310
|
+
emptySeason: 'No matching titles this season.',
|
|
311
|
+
emptySub: 'No subscription in this channel yet. Add one with `anime.sub <ID>`.',
|
|
312
|
+
noAiringSub: (count) => `None of your ${count} subscribed titles air today.`,
|
|
313
|
+
daytimeGroup: 'Airing today',
|
|
314
|
+
tonightGroup: (date) => `Tonight (from 21:00, ${date})`,
|
|
315
|
+
tomorrowDawnGroup: (date) => `After midnight (${date})`,
|
|
316
|
+
seasonLabel: (year, month) => `${year}-${String(month).padStart(2, '0')} season`,
|
|
317
|
+
countUnit: 'titles',
|
|
318
|
+
episodeUnit: (count) => `Episode ${count}`,
|
|
319
|
+
episodeShort: (count) => `#${count}`,
|
|
320
|
+
summaryOf: (total, lateNight) => `${total} titles${lateNight ? `, ${lateNight} of them late night` : ''}`,
|
|
321
|
+
truncated: (shown, total) => `Showing first ${shown} of ${total} titles.`,
|
|
322
|
+
footer: 'Data: bangumi-data / Bangumi',
|
|
323
|
+
rangeLabel: (start, end) => `${start} - ${end}`,
|
|
324
|
+
todayBadge: 'Today',
|
|
325
|
+
subscribedBadge: 'Subscribed',
|
|
326
|
+
moreItems: (count) => `+${count} more`,
|
|
327
|
+
};
|
|
328
|
+
export const MESSAGES = {
|
|
329
|
+
'zh-CN': ZH_CN,
|
|
330
|
+
'ja-JP': JA_JP,
|
|
331
|
+
'en-US': EN_US,
|
|
332
|
+
};
|
|
333
|
+
export function getMessages(locale) {
|
|
334
|
+
return MESSAGES[locale] ?? ZH_CN;
|
|
335
|
+
}
|
|
336
|
+
/** 交互回复:完整句,结尾带句号 */
|
|
337
|
+
export const RESPONSES = {
|
|
338
|
+
needGuild: '请在群聊中使用此指令。',
|
|
339
|
+
invalidKind: (value) => `无效的推送类型: ${value},可选值为 daily、night、weekly、season。`,
|
|
340
|
+
invalidDigest: (value) => `无效的推送类型: ${value},可选值为 daily、night、weekly、season。`,
|
|
341
|
+
invalidTid: (value) => `无效的作品 ID: ${value},请使用 Bangumi 条目 ID(如 \`253104\`)或搜索结果中的序号。`,
|
|
342
|
+
invalidIndex: (value) => `无效的序号: ${value},请使用订阅列表中的序号或作品 ID。`,
|
|
343
|
+
noSubscription: '本群还没有订阅任何番剧,先使用 `anime.search <关键词>` 找到作品,再用 `anime.sub <ID>` 订阅。',
|
|
344
|
+
subscribeLimit: (limit) => `本群订阅数量已达上限 (${limit} 部),请先取消部分订阅。`,
|
|
345
|
+
alreadySubscribed: (title) => `已经订阅过 ${title} 了。`,
|
|
346
|
+
subscribeFailed: '订阅失败,请稍后重试或检查后台日志。',
|
|
347
|
+
databaseFailed: '数据库操作失败,请检查后台日志。',
|
|
348
|
+
renderFailed: '图片渲染失败,已改用文本发送。',
|
|
349
|
+
emptySearch: (keyword) => `未找到与 "${keyword}" 相关的作品,请尝试使用日文原名或更简短的关键词。`,
|
|
350
|
+
notFound: '未找到该作品,它可能不在 bangumi-data 索引里。',
|
|
351
|
+
};
|
|
352
|
+
/** 日志里出现的固定短语 */
|
|
353
|
+
export const LOG_MESSAGES = {
|
|
354
|
+
cleanupFailed: '清理过期推送记录失败:',
|
|
355
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按标题把条目匹配到 AniList。
|
|
3
|
+
*
|
|
4
|
+
* 用于两处:
|
|
5
|
+
* - 运行时给「bangumi-data 没带 `aniList` ID」的条目补 ID(`services/airing.ts`);
|
|
6
|
+
* - 维护脚本 `scripts/match-anilist.ts` 生成离线映射表。
|
|
7
|
+
*
|
|
8
|
+
* 判定标准分两级:
|
|
9
|
+
* - **硬条件**:标题归一化后相等(含 AniList 的 `synonyms`)+ 首播日期相差不超过
|
|
10
|
+
* `MAX_DAY_GAP` 天。前者排除无关条目,后者排除同名的不同季。
|
|
11
|
+
* - **软条件**:播出时刻相差不超过 `MAX_HOUR_GAP` 小时。AniList 记的是「某一时刻可以看」,
|
|
12
|
+
* 与日本电视档期本来就可能差几小时(配信早于电视),因此只用来提示「档期需订正」,
|
|
13
|
+
* 不作为匹配是否成立的条件。
|
|
14
|
+
*/
|
|
15
|
+
/** 首播日期允许的偏差(天) */
|
|
16
|
+
export declare const MAX_DAY_GAP = 30;
|
|
17
|
+
/** 播出时刻允许的偏差(小时);仅用于判断档期是否需订正 */
|
|
18
|
+
export declare const MAX_HOUR_GAP = 6;
|
|
19
|
+
/**
|
|
20
|
+
* 归一化标题:去空白与标点、全角转半角、罗马数字统一、去季度后缀。
|
|
21
|
+
*
|
|
22
|
+
* 目的是让「転生貴族、鑑定スキルで成り上がる(第3期)」与
|
|
23
|
+
* 「転生貴族、鑑定スキルで成り上がる 第3期」这类写法差异不影响匹配。
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeTitle(value: unknown): string;
|
|
26
|
+
/** 一条待匹配的作品信息 */
|
|
27
|
+
export interface MatchTarget {
|
|
28
|
+
title: string;
|
|
29
|
+
/** 开播日期(UTC 毫秒,来自 bangumi-data 的 `begin`) */
|
|
30
|
+
beginMs?: number | null;
|
|
31
|
+
/** 首次播出时刻(UTC 毫秒,来自周期规则或订正表) */
|
|
32
|
+
broadcastMs?: number | null;
|
|
33
|
+
}
|
|
34
|
+
/** AniList 候选里用得上的字段 */
|
|
35
|
+
export interface MatchCandidate {
|
|
36
|
+
id: number;
|
|
37
|
+
title?: {
|
|
38
|
+
native?: string | null;
|
|
39
|
+
romaji?: string | null;
|
|
40
|
+
english?: string | null;
|
|
41
|
+
} | null;
|
|
42
|
+
synonyms?: string[] | null;
|
|
43
|
+
startDate?: {
|
|
44
|
+
year?: number | null;
|
|
45
|
+
month?: number | null;
|
|
46
|
+
day?: number | null;
|
|
47
|
+
} | null;
|
|
48
|
+
airingSchedule?: {
|
|
49
|
+
nodes?: {
|
|
50
|
+
episode?: number | null;
|
|
51
|
+
airingAt?: number | null;
|
|
52
|
+
}[] | null;
|
|
53
|
+
} | null;
|
|
54
|
+
}
|
|
55
|
+
/** 匹配结果 */
|
|
56
|
+
export interface TitleMatch {
|
|
57
|
+
candidate: MatchCandidate;
|
|
58
|
+
/** 首播日期相差的天数;任一侧缺失时为 null */
|
|
59
|
+
dayGap: number | null;
|
|
60
|
+
/** 播出时刻相差的小时数;任一侧缺失时为 null */
|
|
61
|
+
hourGap: number | null;
|
|
62
|
+
/** 时刻是否也对得上——对不上说明档期需要订正 */
|
|
63
|
+
timeAgrees: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 在候选里挑出与目标最匹配的一个。
|
|
67
|
+
*
|
|
68
|
+
* 返回 null 表示没有候选满足硬条件——此时应当**放弃匹配**,退回周期规则的推算,
|
|
69
|
+
* 而不是猜一个「看起来像」的。
|
|
70
|
+
*/
|
|
71
|
+
export declare function matchByTitle(target: MatchTarget, candidates: MatchCandidate[]): TitleMatch | null;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按标题把条目匹配到 AniList。
|
|
3
|
+
*
|
|
4
|
+
* 用于两处:
|
|
5
|
+
* - 运行时给「bangumi-data 没带 `aniList` ID」的条目补 ID(`services/airing.ts`);
|
|
6
|
+
* - 维护脚本 `scripts/match-anilist.ts` 生成离线映射表。
|
|
7
|
+
*
|
|
8
|
+
* 判定标准分两级:
|
|
9
|
+
* - **硬条件**:标题归一化后相等(含 AniList 的 `synonyms`)+ 首播日期相差不超过
|
|
10
|
+
* `MAX_DAY_GAP` 天。前者排除无关条目,后者排除同名的不同季。
|
|
11
|
+
* - **软条件**:播出时刻相差不超过 `MAX_HOUR_GAP` 小时。AniList 记的是「某一时刻可以看」,
|
|
12
|
+
* 与日本电视档期本来就可能差几小时(配信早于电视),因此只用来提示「档期需订正」,
|
|
13
|
+
* 不作为匹配是否成立的条件。
|
|
14
|
+
*/
|
|
15
|
+
/** 首播日期允许的偏差(天) */
|
|
16
|
+
export const MAX_DAY_GAP = 30;
|
|
17
|
+
/** 播出时刻允许的偏差(小时);仅用于判断档期是否需订正 */
|
|
18
|
+
export const MAX_HOUR_GAP = 6;
|
|
19
|
+
const HOUR = 3600000;
|
|
20
|
+
const DAY = 86400000;
|
|
21
|
+
/**
|
|
22
|
+
* 归一化标题:去空白与标点、全角转半角、罗马数字统一、去季度后缀。
|
|
23
|
+
*
|
|
24
|
+
* 目的是让「転生貴族、鑑定スキルで成り上がる(第3期)」与
|
|
25
|
+
* 「転生貴族、鑑定スキルで成り上がる 第3期」这类写法差异不影响匹配。
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeTitle(value) {
|
|
28
|
+
return String(value ?? '')
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[0-9a-zA-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
|
|
31
|
+
.replace(/[\s\u3000_\-–—~~·・::!!??.,,。'"“”'‘’()()[\]【】〈〉《》☆★]/g, '')
|
|
32
|
+
.replace(/[ⅠⅡⅢⅣⅤ]/g, (char) => 'I'.repeat('ⅠⅡⅢⅣⅤ'.indexOf(char) + 1))
|
|
33
|
+
.replace(/第(\d+)期|第(\d+)季|第(\d+)クール|シーズン\d+|season\d+|\d+(st|nd|rd|th)season/g, '');
|
|
34
|
+
}
|
|
35
|
+
/** `FuzzyDate` → UTC 毫秒;不完整时返回 null */
|
|
36
|
+
function startMsOf(candidate) {
|
|
37
|
+
const date = candidate.startDate;
|
|
38
|
+
if (!date?.year)
|
|
39
|
+
return null;
|
|
40
|
+
return Date.UTC(date.year, (date.month ?? 1) - 1, date.day ?? 1);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 在候选里挑出与目标最匹配的一个。
|
|
44
|
+
*
|
|
45
|
+
* 返回 null 表示没有候选满足硬条件——此时应当**放弃匹配**,退回周期规则的推算,
|
|
46
|
+
* 而不是猜一个「看起来像」的。
|
|
47
|
+
*/
|
|
48
|
+
export function matchByTitle(target, candidates) {
|
|
49
|
+
const wanted = normalizeTitle(target.title);
|
|
50
|
+
if (!wanted)
|
|
51
|
+
return null;
|
|
52
|
+
const beginMs = target.beginMs ?? null;
|
|
53
|
+
const topMs = target.broadcastMs ?? Number.NaN;
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
const names = [
|
|
57
|
+
candidate.title?.native,
|
|
58
|
+
candidate.title?.romaji,
|
|
59
|
+
candidate.title?.english,
|
|
60
|
+
...(candidate.synonyms ?? []),
|
|
61
|
+
];
|
|
62
|
+
if (!names.some((name) => name && normalizeTitle(name) === wanted))
|
|
63
|
+
continue;
|
|
64
|
+
const startMs = startMsOf(candidate);
|
|
65
|
+
const dayGap = startMs !== null && beginMs !== null ? Math.abs(startMs - beginMs) / DAY : null;
|
|
66
|
+
if (dayGap !== null && dayGap > MAX_DAY_GAP)
|
|
67
|
+
continue;
|
|
68
|
+
const nodes = (candidate.airingSchedule?.nodes ?? []).filter((node) => typeof node.airingAt === 'number');
|
|
69
|
+
const first = nodes.length
|
|
70
|
+
? nodes.reduce((a, b) => ((a.airingAt ?? 0) < (b.airingAt ?? 0) ? a : b))
|
|
71
|
+
: null;
|
|
72
|
+
const hourGap = first && Number.isFinite(topMs)
|
|
73
|
+
? Math.abs(first.airingAt * 1000 - topMs) / HOUR
|
|
74
|
+
: null;
|
|
75
|
+
const timeAgrees = hourGap === null || hourGap <= MAX_HOUR_GAP;
|
|
76
|
+
const current = { candidate, dayGap, hourGap, timeAgrees };
|
|
77
|
+
// 优先取「时刻也对得上」的候选;同类则取首播日期更接近的
|
|
78
|
+
const better = !best
|
|
79
|
+
|| (current.timeAgrees && !best.timeAgrees)
|
|
80
|
+
|| (current.timeAgrees === best.timeAgrees && (dayGap ?? 9999) < (best.dayGap ?? 9999));
|
|
81
|
+
if (better)
|
|
82
|
+
best = current;
|
|
83
|
+
}
|
|
84
|
+
return best;
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context, Logger } from 'koishi';
|
|
2
|
+
import type { BangumiDataItem } from './bangumi-data-source';
|
|
3
|
+
/** 一次载入的结果 */
|
|
4
|
+
export interface LoadedData {
|
|
5
|
+
items: BangumiDataItem[];
|
|
6
|
+
/** 来源描述,用于 `anime.status` 与日志 */
|
|
7
|
+
source: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class BangumiDataLoader {
|
|
10
|
+
private ctx;
|
|
11
|
+
private logger;
|
|
12
|
+
private readonly http;
|
|
13
|
+
constructor(ctx: Context, logger: Logger);
|
|
14
|
+
private get cachePath();
|
|
15
|
+
/** 依次尝试:npm 依赖包 → 磁盘缓存 → CDN 镜像 */
|
|
16
|
+
load(): Promise<LoadedData | null>;
|
|
17
|
+
/** 从 npm 依赖读取 */
|
|
18
|
+
private loadFromPackage;
|
|
19
|
+
private loadFromCache;
|
|
20
|
+
private writeCache;
|
|
21
|
+
/** CDN 镜像回退(GitHub raw 在部分网络下不可达) */
|
|
22
|
+
private loadFromNetwork;
|
|
23
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bangumi-data 的取数层。
|
|
3
|
+
*
|
|
4
|
+
* 数据来自 npm 包 `bangumi-data`(`main` 指向 `dist/data.json`),随插件一起安装、
|
|
5
|
+
* 无需联网;只有包缺失时才依次回退到磁盘缓存与 CDN 镜像。
|
|
6
|
+
*
|
|
7
|
+
* 这一层只负责「把 item 列表拿回来」,不解析、不建索引。
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fs } from 'fs';
|
|
10
|
+
import { dirname, resolve } from 'path';
|
|
11
|
+
import { BANGUMI_DATA_FALLBACK_URLS, BANGUMI_DATA_FILE, DATA_DIR } from '../constants';
|
|
12
|
+
import { errorMessage, unwrapText } from '../utils';
|
|
13
|
+
import { HttpClient } from '../services/network';
|
|
14
|
+
/** 只在缓存里保留用得到的字段,避免把整个包写一遍 */
|
|
15
|
+
const CACHED_FIELDS = [
|
|
16
|
+
'title', 'titleTranslate', 'type', 'officialSite', 'begin', 'broadcast', 'end', 'sites',
|
|
17
|
+
];
|
|
18
|
+
export class BangumiDataLoader {
|
|
19
|
+
ctx;
|
|
20
|
+
logger;
|
|
21
|
+
http;
|
|
22
|
+
constructor(ctx, logger) {
|
|
23
|
+
this.ctx = ctx;
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
this.http = new HttpClient(ctx, logger);
|
|
26
|
+
}
|
|
27
|
+
get cachePath() {
|
|
28
|
+
return resolve(this.ctx.baseDir, ...DATA_DIR, BANGUMI_DATA_FILE);
|
|
29
|
+
}
|
|
30
|
+
/** 依次尝试:npm 依赖包 → 磁盘缓存 → CDN 镜像 */
|
|
31
|
+
async load() {
|
|
32
|
+
const fromPackage = this.loadFromPackage();
|
|
33
|
+
if (fromPackage)
|
|
34
|
+
return { items: fromPackage, source: '本地依赖包' };
|
|
35
|
+
const fromCache = await this.loadFromCache();
|
|
36
|
+
if (fromCache)
|
|
37
|
+
return { items: fromCache, source: '磁盘缓存' };
|
|
38
|
+
const fromNetwork = await this.loadFromNetwork();
|
|
39
|
+
if (fromNetwork) {
|
|
40
|
+
await this.writeCache(fromNetwork);
|
|
41
|
+
return { items: fromNetwork, source: 'CDN 镜像' };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** 从 npm 依赖读取 */
|
|
46
|
+
loadFromPackage() {
|
|
47
|
+
try {
|
|
48
|
+
const mod = require('bangumi-data');
|
|
49
|
+
const items = Array.isArray(mod) ? mod : mod?.items;
|
|
50
|
+
if (Array.isArray(items) && items.length)
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.logger.debug('[index] 本地依赖包不可用: %s', errorMessage(error));
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
async loadFromCache() {
|
|
59
|
+
try {
|
|
60
|
+
const text = await fs.readFile(this.cachePath, 'utf8');
|
|
61
|
+
const parsed = JSON.parse(text);
|
|
62
|
+
if (Array.isArray(parsed.items) && parsed.items.length)
|
|
63
|
+
return parsed.items;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== 'ENOENT') {
|
|
67
|
+
this.logger.debug('[index] 读取缓存失败: %s', errorMessage(error));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
async writeCache(items) {
|
|
73
|
+
const temp = `${this.cachePath}.tmp`;
|
|
74
|
+
try {
|
|
75
|
+
await fs.mkdir(dirname(this.cachePath), { recursive: true });
|
|
76
|
+
const slim = items.map((item) => {
|
|
77
|
+
const picked = {};
|
|
78
|
+
for (const key of CACHED_FIELDS)
|
|
79
|
+
if (item[key] !== undefined)
|
|
80
|
+
picked[key] = item[key];
|
|
81
|
+
return picked;
|
|
82
|
+
});
|
|
83
|
+
await fs.writeFile(temp, JSON.stringify({ items: slim }), 'utf8');
|
|
84
|
+
await fs.rename(temp, this.cachePath);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await fs.rm(temp, { force: true }).catch(() => undefined);
|
|
88
|
+
this.logger.debug('[index] 写入缓存失败: %s', errorMessage(error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** CDN 镜像回退(GitHub raw 在部分网络下不可达) */
|
|
92
|
+
async loadFromNetwork() {
|
|
93
|
+
for (const url of BANGUMI_DATA_FALLBACK_URLS) {
|
|
94
|
+
try {
|
|
95
|
+
const raw = await this.http.get(url, { timeout: 60000, responseType: 'text' });
|
|
96
|
+
const text = unwrapText(raw);
|
|
97
|
+
if (!text)
|
|
98
|
+
continue;
|
|
99
|
+
const parsed = JSON.parse(text);
|
|
100
|
+
if (Array.isArray(parsed.items) && parsed.items.length)
|
|
101
|
+
return parsed.items;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
this.logger.debug('[index] 镜像 %s 获取失败: %s', url, errorMessage(error));
|
|
105
|
+
this.http.hintOnFailure(error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|