koishi-plugin-anime-schedule 1.0.6 → 1.0.8
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 +2 -3
- package/lib/constants.d.ts +24 -0
- package/lib/constants.js +48 -0
- package/lib/handlers.d.ts +11 -1
- package/lib/handlers.js +74 -13
- package/lib/index.js +43 -27
- package/lib/render/covers.d.ts +2 -2
- package/lib/render/image.d.ts +18 -16
- package/lib/render/image.js +84 -0
- package/lib/render/info.d.ts +49 -0
- package/lib/render/info.js +401 -0
- package/lib/render/text.d.ts +17 -1
- package/lib/render/text.js +66 -0
- package/lib/services/digest.d.ts +7 -0
- package/lib/services/digest.js +17 -9
- package/lib/services/info.d.ts +132 -0
- package/lib/services/info.js +303 -0
- package/lib/types.d.ts +28 -1
- package/lib/utils/time.d.ts +7 -0
- package/lib/utils/time.js +13 -0
- package/package.json +2 -2
- package/readme.md +5 -2
package/lib/config.js
CHANGED
|
@@ -93,7 +93,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
93
93
|
.description('单张图片最多加载多少张封面,0 为不限制')
|
|
94
94
|
.default(0),
|
|
95
95
|
showCover: koishi_1.Schema.boolean().description('显示封面').default(true),
|
|
96
|
-
showScore: koishi_1.Schema.boolean().description('
|
|
96
|
+
showScore: koishi_1.Schema.boolean().description('显示评分,评价不足 10 人时不显示').default(true),
|
|
97
97
|
useForward: koishi_1.Schema.boolean()
|
|
98
98
|
.description('纯文本模式使用合并转发,避免刷屏')
|
|
99
99
|
.default(true),
|
|
@@ -113,8 +113,7 @@ exports.Config = koishi_1.Schema.intersect([
|
|
|
113
113
|
.default('localized'),
|
|
114
114
|
scoreTtlHours: koishi_1.Schema.number()
|
|
115
115
|
.min(1).step(1)
|
|
116
|
-
.description('
|
|
117
|
-
+ '因此单独给一个比标题封面短得多的保质期')
|
|
116
|
+
.description('评分缓存小时数')
|
|
118
117
|
.default(constants_1.DEFAULT_SCORE_TTL_HOURS),
|
|
119
118
|
cacheTtlDays: koishi_1.Schema.number()
|
|
120
119
|
.min(1).step(1)
|
package/lib/constants.d.ts
CHANGED
|
@@ -260,6 +260,30 @@ export interface Messages {
|
|
|
260
260
|
subscribedBadge: string;
|
|
261
261
|
/** 日期列超出上限时的提示 */
|
|
262
262
|
moreItems: (count: number) => string;
|
|
263
|
+
/** 详情卡页脚的数据更新时间 */
|
|
264
|
+
infoUpdated: (moment: string) => string;
|
|
265
|
+
/** 连载状态 */
|
|
266
|
+
infoAiring: string;
|
|
267
|
+
infoFinished: string;
|
|
268
|
+
infoUpcoming: string;
|
|
269
|
+
infoUnknown: string;
|
|
270
|
+
/** 区块标题 */
|
|
271
|
+
infoSummary: string;
|
|
272
|
+
infoProgress: string;
|
|
273
|
+
infoScoreBoard: string;
|
|
274
|
+
/** 单位与占位文案 */
|
|
275
|
+
infoVotes: string;
|
|
276
|
+
infoEpisodes: string;
|
|
277
|
+
infoAired: string;
|
|
278
|
+
/** 详情里「下一话」的标签 */
|
|
279
|
+
infoNext: string;
|
|
280
|
+
/** 拿不到评分或档期时的占位 */
|
|
281
|
+
infoNoScore: string;
|
|
282
|
+
infoNoSchedule: string;
|
|
283
|
+
/** 搜索结果的标题行 */
|
|
284
|
+
searchHeader: (count: number, keyword: string) => string;
|
|
285
|
+
/** 时间轴上「现在」的分界线标签 */
|
|
286
|
+
nowLabel: (clock: string) => string;
|
|
263
287
|
}
|
|
264
288
|
export declare const MESSAGES: Record<PluginLocale, Messages>;
|
|
265
289
|
export declare function getMessages(locale: PluginLocale): Messages;
|
package/lib/constants.js
CHANGED
|
@@ -286,6 +286,22 @@ const ZH_CN = {
|
|
|
286
286
|
todayBadge: '今天',
|
|
287
287
|
subscribedBadge: '已订阅',
|
|
288
288
|
moreItems: (count) => `还有 ${count} 部`,
|
|
289
|
+
infoUpdated: (moment) => `数据更新于 ${moment}`,
|
|
290
|
+
infoAiring: '连载中',
|
|
291
|
+
infoFinished: '已完结',
|
|
292
|
+
infoUpcoming: '未开播',
|
|
293
|
+
infoUnknown: '状态未知',
|
|
294
|
+
infoSummary: '简介',
|
|
295
|
+
infoProgress: '放送进度',
|
|
296
|
+
infoScoreBoard: '评分分布',
|
|
297
|
+
infoVotes: '人评分',
|
|
298
|
+
infoEpisodes: '话',
|
|
299
|
+
infoAired: '已播出',
|
|
300
|
+
infoNext: '下一话',
|
|
301
|
+
infoNoScore: '评分人数不足',
|
|
302
|
+
infoNoSchedule: '暂无逐集档期',
|
|
303
|
+
nowLabel: (clock) => `现在 ${clock}`,
|
|
304
|
+
searchHeader: (count, keyword) => `找到 ${count} 部与 "${keyword}" 相关的作品,用 \`anime.info <序号>\` 看详情、\`anime.sub <序号>\` 订阅:`,
|
|
289
305
|
};
|
|
290
306
|
const JA_JP = {
|
|
291
307
|
...ZH_CN,
|
|
@@ -315,6 +331,22 @@ const JA_JP = {
|
|
|
315
331
|
todayBadge: '今日',
|
|
316
332
|
subscribedBadge: '登録済み',
|
|
317
333
|
moreItems: (count) => `他 ${count} 作品`,
|
|
334
|
+
infoUpdated: (moment) => `データ更新 ${moment}`,
|
|
335
|
+
infoAiring: '放送中',
|
|
336
|
+
infoFinished: '完結',
|
|
337
|
+
infoUpcoming: '放送前',
|
|
338
|
+
infoUnknown: '状態不明',
|
|
339
|
+
infoSummary: 'あらすじ',
|
|
340
|
+
infoProgress: '放送進捗',
|
|
341
|
+
infoScoreBoard: 'スコア分布',
|
|
342
|
+
infoVotes: '人評価',
|
|
343
|
+
infoEpisodes: '話',
|
|
344
|
+
infoAired: '放送済み',
|
|
345
|
+
infoNext: '次回',
|
|
346
|
+
infoNoScore: '評価者不足',
|
|
347
|
+
infoNoSchedule: '話数情報なし',
|
|
348
|
+
nowLabel: (clock) => `現在 ${clock}`,
|
|
349
|
+
searchHeader: (count, keyword) => `「${keyword}」に一致する作品が ${count} 件。\`anime.info <番号>\` で詳細、\`anime.sub <番号>\` で登録:`,
|
|
318
350
|
};
|
|
319
351
|
const EN_US = {
|
|
320
352
|
...ZH_CN,
|
|
@@ -344,6 +376,22 @@ const EN_US = {
|
|
|
344
376
|
todayBadge: 'Today',
|
|
345
377
|
subscribedBadge: 'Subscribed',
|
|
346
378
|
moreItems: (count) => `+${count} more`,
|
|
379
|
+
infoUpdated: (moment) => `Updated ${moment}`,
|
|
380
|
+
infoAiring: 'Airing',
|
|
381
|
+
infoFinished: 'Finished',
|
|
382
|
+
infoUpcoming: 'Upcoming',
|
|
383
|
+
infoUnknown: 'Unknown',
|
|
384
|
+
infoSummary: 'Synopsis',
|
|
385
|
+
infoProgress: 'Progress',
|
|
386
|
+
infoScoreBoard: 'Score distribution',
|
|
387
|
+
infoVotes: 'ratings',
|
|
388
|
+
infoEpisodes: 'eps',
|
|
389
|
+
infoAired: 'Aired',
|
|
390
|
+
infoNext: 'Next',
|
|
391
|
+
infoNoScore: 'Too few ratings',
|
|
392
|
+
infoNoSchedule: 'No episode schedule',
|
|
393
|
+
nowLabel: (clock) => `Now ${clock}`,
|
|
394
|
+
searchHeader: (count, keyword) => `Found ${count} titles matching "${keyword}". Use \`anime.info <n>\` for details or \`anime.sub <n>\` to subscribe:`,
|
|
347
395
|
};
|
|
348
396
|
exports.MESSAGES = {
|
|
349
397
|
'zh-CN': ZH_CN,
|
package/lib/handlers.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Context, Logger, Session } from 'koishi';
|
|
1
|
+
import { Context, h, Logger, Session } from 'koishi';
|
|
2
2
|
import type { Config } from './config';
|
|
3
3
|
import type { DigestService } from './services/digest';
|
|
4
|
+
import type { InfoService } from './services/info';
|
|
4
5
|
import type { Renderer } from './render/image';
|
|
5
6
|
/** 指令层依赖集合 */
|
|
6
7
|
export interface HandlerContext {
|
|
@@ -9,6 +10,8 @@ export interface HandlerContext {
|
|
|
9
10
|
logger: Logger;
|
|
10
11
|
digest: DigestService;
|
|
11
12
|
renderer: Renderer;
|
|
13
|
+
/** 单部作品详情(`anime.info`),与推送路径无关,只服务交互查询 */
|
|
14
|
+
info: InfoService;
|
|
12
15
|
}
|
|
13
16
|
/** 手动查看当日更新表,`offset` 为相对今天的日期偏移 */
|
|
14
17
|
export declare function commandToday(deps: HandlerContext, session: Session, offset?: number): Promise<void>;
|
|
@@ -32,6 +35,13 @@ export declare function commandPush(deps: HandlerContext, session: Session, kind
|
|
|
32
35
|
export declare function commandSearch(deps: HandlerContext, session: Session, keyword: string, options?: {
|
|
33
36
|
limit?: number;
|
|
34
37
|
}): Promise<string | void>;
|
|
38
|
+
/**
|
|
39
|
+
* 单部作品详情。
|
|
40
|
+
*
|
|
41
|
+
* 目标可以是 Bangumi 条目 ID,也可以是上一次搜索结果里的序号(与 `anime.sub` 同一套解析),
|
|
42
|
+
* 因此「搜索 → 看详情 → 订阅」是一条连贯的路径。
|
|
43
|
+
*/
|
|
44
|
+
export declare function commandInfo(deps: HandlerContext, session: Session, target: string): Promise<string | h | void>;
|
|
35
45
|
/**
|
|
36
46
|
* 查看本群订阅列表。
|
|
37
47
|
*
|
package/lib/handlers.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.commandWeekly = commandWeekly;
|
|
|
6
6
|
exports.commandSeason = commandSeason;
|
|
7
7
|
exports.commandPush = commandPush;
|
|
8
8
|
exports.commandSearch = commandSearch;
|
|
9
|
+
exports.commandInfo = commandInfo;
|
|
9
10
|
exports.commandSubList = commandSubList;
|
|
10
11
|
exports.commandSubAdd = commandSubAdd;
|
|
11
12
|
exports.commandSubRemove = commandSubRemove;
|
|
@@ -17,6 +18,7 @@ exports.commandStatus = commandStatus;
|
|
|
17
18
|
const koishi_1 = require("koishi");
|
|
18
19
|
const constants_1 = require("./constants");
|
|
19
20
|
const dispatch_1 = require("./services/dispatch");
|
|
21
|
+
const text_1 = require("./render/text");
|
|
20
22
|
const time_1 = require("./utils/time");
|
|
21
23
|
const utils_1 = require("./utils");
|
|
22
24
|
const KIND_ALIASES = {
|
|
@@ -69,6 +71,21 @@ function writeSearchCache(session, items) {
|
|
|
69
71
|
function channelOf(session) {
|
|
70
72
|
return session.channelId ?? '';
|
|
71
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 把索引层给内部的来源说法翻成用户能读的短句。
|
|
76
|
+
*
|
|
77
|
+
* `BangumiDataIndex.source` 返回的是 `本地依赖包` / `磁盘缓存` / `CDN 镜像` 这类
|
|
78
|
+
* **面向日志**的词,直接摆到 `anime.status` 里用户看不懂,也判断不出该不该在意
|
|
79
|
+
* ——正常情况是"随插件内置",出现另外两种才值得看一眼。
|
|
80
|
+
*/
|
|
81
|
+
function describeIndexSource(source) {
|
|
82
|
+
switch (source) {
|
|
83
|
+
case '本地依赖包': return '随插件内置 (bangumi-data)';
|
|
84
|
+
case '磁盘缓存': return '本地磁盘缓存 (bangumi-data)';
|
|
85
|
+
case 'CDN 镜像': return '已从远程更新 (bangumi-data)';
|
|
86
|
+
default: return source || '未知';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
72
89
|
function platformOf(session) {
|
|
73
90
|
return session.platform ?? '';
|
|
74
91
|
}
|
|
@@ -84,6 +101,7 @@ async function replyDigest(deps, session, digest) {
|
|
|
84
101
|
try {
|
|
85
102
|
const buffer = await renderer.render({
|
|
86
103
|
kind: digest.kind,
|
|
104
|
+
now: digest.generatedAt,
|
|
87
105
|
header: digest.header,
|
|
88
106
|
summary: digest.summary,
|
|
89
107
|
groups: digest.groups,
|
|
@@ -237,24 +255,65 @@ async function commandSearch(deps, session, keyword, options = {}) {
|
|
|
237
255
|
if (!items.length)
|
|
238
256
|
return constants_1.RESPONSES.emptySearch(query);
|
|
239
257
|
writeSearchCache(session, items);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const meta = [when, item.type ? item.type.toUpperCase() : ''].filter(Boolean).join(' · ');
|
|
245
|
-
// 显示纯数字 ID,与 `anime.sub <ID>` 的写法一致,便于直接复制
|
|
246
|
-
return `${index + 1}. [ID:${item.bgmId ?? '-'}] ${(0, utils_1.truncate)(item.name, 60)}\n ${meta}`;
|
|
247
|
-
});
|
|
248
|
-
return [
|
|
249
|
-
`找到 ${items.length} 部与 "${query}" 相关的作品,使用 \`anime.sub <ID>\` 或结果序号订阅:`,
|
|
250
|
-
...lines,
|
|
251
|
-
].join('\n');
|
|
258
|
+
// 只给文本列表(带评分与档期),不逐条渲染详情卡:
|
|
259
|
+
// 一次搜索常有十来个结果,十张卡片刷屏反而看不出哪个是哪个。
|
|
260
|
+
// 想看详情时用 `anime.info <序号>` 或直接点 ID。
|
|
261
|
+
return (0, text_1.renderSearchText)(items, query, deps.config);
|
|
252
262
|
}
|
|
253
263
|
catch (error) {
|
|
254
264
|
deps.logger.warn('[command] 搜索失败:', error);
|
|
255
265
|
return `搜索失败。${(0, utils_1.formatNetworkError)(error)}`;
|
|
256
266
|
}
|
|
257
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* 单部作品详情。
|
|
270
|
+
*
|
|
271
|
+
* 目标可以是 Bangumi 条目 ID,也可以是上一次搜索结果里的序号(与 `anime.sub` 同一套解析),
|
|
272
|
+
* 因此「搜索 → 看详情 → 订阅」是一条连贯的路径。
|
|
273
|
+
*/
|
|
274
|
+
async function commandInfo(deps, session, target) {
|
|
275
|
+
const raw = (target ?? '').trim();
|
|
276
|
+
if (!raw)
|
|
277
|
+
return '用法: anime.info <Bangumi 条目 ID 或搜索结果序号>';
|
|
278
|
+
let bgmId = null;
|
|
279
|
+
if (BGM_ID.test(raw)) {
|
|
280
|
+
bgmId = Number(raw);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// 不是纯数字就当作搜索关键词,省掉「先 search 再 info」这一步
|
|
284
|
+
const items = await deps.digest.search(raw, 1);
|
|
285
|
+
bgmId = items[0]?.bgmId ?? null;
|
|
286
|
+
if (bgmId === null)
|
|
287
|
+
return constants_1.RESPONSES.notFound;
|
|
288
|
+
}
|
|
289
|
+
await session.send((0, koishi_1.h)('quote', { id: session.messageId }) + `正在查询 ID:${bgmId}...`);
|
|
290
|
+
try {
|
|
291
|
+
const subscribed = await isSubscribed(deps, session, `bgm:${bgmId}`);
|
|
292
|
+
const detail = await deps.info.fetch(bgmId, subscribed);
|
|
293
|
+
if (!detail)
|
|
294
|
+
return constants_1.RESPONSES.notFound;
|
|
295
|
+
if (deps.config.output.format !== 'image')
|
|
296
|
+
return (0, text_1.renderInfoText)(detail, deps.config);
|
|
297
|
+
try {
|
|
298
|
+
const buffer = await deps.renderer.renderInfo(detail);
|
|
299
|
+
return koishi_1.h.image(buffer, 'image/jpeg');
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
// 与推送表同样兜底:出图失败就退回文本,不让用户拿到一个错误
|
|
303
|
+
deps.logger.warn('[command] 详情出图失败,改用文本:', error);
|
|
304
|
+
return (0, text_1.renderInfoText)(detail, deps.config);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
deps.logger.warn('[command] 查询作品详情失败:', error);
|
|
309
|
+
return `查询失败。${(0, utils_1.formatNetworkError)(error)}`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** 该作品是否已被本群订阅 */
|
|
313
|
+
async function isSubscribed(deps, session, tid) {
|
|
314
|
+
const rows = await deps.digest.listSubscriptions(platformOf(session), channelOf(session));
|
|
315
|
+
return rows.some((row) => row.tid === tid);
|
|
316
|
+
}
|
|
258
317
|
// #endregion
|
|
259
318
|
// #region 订阅管理
|
|
260
319
|
/**
|
|
@@ -493,7 +552,9 @@ async function commandStatus(deps, session) {
|
|
|
493
552
|
`时区: ${config.timeZone} (当前 ${(0, time_1.formatDate)(Date.now(), config.timeZone)})`,
|
|
494
553
|
`推送形态: ${formatText}`,
|
|
495
554
|
`本地化: ${digest.localizer.enabled ? `已开启 (${config.localize.titleStyle})` : '已关闭'}`,
|
|
496
|
-
|
|
555
|
+
// 用索引层的真实来源,不写死:`bangumi-data` 取不到时会退化为远程拉取,
|
|
556
|
+
// 而状态页正是用来诊断这种情况的,文案必须跟着变
|
|
557
|
+
`数据来源: ${describeIndexSource(digest.index.source)} (${digest.index.size} 部) / Bangumi`,
|
|
497
558
|
`推送时刻: ${describeTimes(config)}`,
|
|
498
559
|
`开关状态: 每日 ${channel?.dailyEnabled ? '开' : '关'} / 深夜档 ${channel?.nightEnabled ? '开' : '关'} / 本周 ${channel?.weeklyEnabled ? '开' : '关'} / 本季 ${channel?.seasonEnabled ? '开' : '关'}`,
|
|
499
560
|
`订阅番剧: ${subscriptions.length} 部${config.subscription.maxPerChannel > 0 ? ` (上限 ${config.subscription.maxPerChannel})` : ''}`,
|
package/lib/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const dispatch_1 = require("./services/dispatch");
|
|
|
14
14
|
const scheduler_1 = require("./services/scheduler");
|
|
15
15
|
const image_1 = require("./render/image");
|
|
16
16
|
const handlers_1 = require("./handlers");
|
|
17
|
+
const info_1 = require("./services/info");
|
|
17
18
|
const utils_1 = require("./utils");
|
|
18
19
|
const time_1 = require("./utils/time");
|
|
19
20
|
/** 把时区偏移写成 `+8` / `-3` 这样的短文本,用于日志与图片页眉 */
|
|
@@ -23,32 +24,32 @@ function formatOffset(timeZone) {
|
|
|
23
24
|
}
|
|
24
25
|
exports.name = constants_1.PLUGIN_NAME;
|
|
25
26
|
exports.inject = { required: ['database', 'http'] };
|
|
26
|
-
exports.usage = `
|
|
27
|
-
番剧更新表与订阅提醒。作品清单来自 [bangumi-data](https://github.com/bangumi-data/bangumi-data),
|
|
28
|
-
排期、封面查询 [AniList](https://anilist.co/),中文标题、封面与评分取自 [Bangumi](https://bgm.tv/)。
|
|
29
|
-
|
|
30
|
-
在群聊里执行一次 \`anime.on\` 后开始接收定时推送。
|
|
31
|
-
|
|
32
|
-
| 指令 | 别名 | 说明 |
|
|
33
|
-
| :--- | :--- | :--- |
|
|
34
|
-
| \`anime.today\` | 今日新番 | 今日更新表,\`-o -1\` 看前一天 |
|
|
35
|
-
| \`anime.night\` | 深夜新番 | 今晚 21:00 至次日 06:00 |
|
|
36
|
-
| \`anime.week\` | 本周新番 | 今天起七天 |
|
|
37
|
-
| \`anime.season [季度]\` | 本季新番 | 本季一览,也可查往季 |
|
|
38
|
-
| \`anime.search <关键词>\` | 番剧搜索 | 中日英文标题都能搜 |
|
|
39
|
-
| \`anime.sub <ID>\` | 番剧订阅 | 用 Bangumi 条目 ID 订阅 |
|
|
40
|
-
| \`anime.remove <ID>\` | 取消订阅 | 也接受订阅列表里的序号 |
|
|
41
|
-
| \`anime.list\` | 订阅列表 | 本群订阅的番剧 |
|
|
42
|
-
| \`anime.clear\` | 清空订阅 | 清空本群订阅 |
|
|
43
|
-
| \`anime.next [天数]\` | 追番计划 | 订阅作品未来排期 |
|
|
44
|
-
| \`anime.on\` / \`anime.off\` | 开启推送 / 关闭推送 | 本群定时推送开关 |
|
|
45
|
-
| \`anime.status\` | 推送状态 | 本群推送状态 |
|
|
46
|
-
| \`anime.push <类型>\` | 立即推送 | 手动发一次推送 |
|
|
47
|
-
|
|
48
|
-
每日、深夜档与本周只列还在播的作品,本季表收录该季首播的全部 TV / WEB 动画。
|
|
49
|
-
卡片上的 \`ID:xxx\` 可直接用于订阅。
|
|
50
|
-
|
|
51
|
-
若无法获取封面图和评分,请尝试配置 proxy-agent 代理设置。
|
|
27
|
+
exports.usage = `
|
|
28
|
+
番剧更新表与订阅提醒。作品清单来自 [bangumi-data](https://github.com/bangumi-data/bangumi-data),
|
|
29
|
+
排期、封面查询 [AniList](https://anilist.co/),中文标题、封面与评分取自 [Bangumi](https://bgm.tv/)。
|
|
30
|
+
|
|
31
|
+
在群聊里执行一次 \`anime.on\` 后开始接收定时推送。
|
|
32
|
+
|
|
33
|
+
| 指令 | 别名 | 说明 |
|
|
34
|
+
| :--- | :--- | :--- |
|
|
35
|
+
| \`anime.today\` | 今日新番 | 今日更新表,\`-o -1\` 看前一天 |
|
|
36
|
+
| \`anime.night\` | 深夜新番 | 今晚 21:00 至次日 06:00 |
|
|
37
|
+
| \`anime.week\` | 本周新番 | 今天起七天 |
|
|
38
|
+
| \`anime.season [季度]\` | 本季新番 | 本季一览,也可查往季 |
|
|
39
|
+
| \`anime.search <关键词>\` | 番剧搜索 | 中日英文标题都能搜 |
|
|
40
|
+
| \`anime.sub <ID>\` | 番剧订阅 | 用 Bangumi 条目 ID 订阅 |
|
|
41
|
+
| \`anime.remove <ID>\` | 取消订阅 | 也接受订阅列表里的序号 |
|
|
42
|
+
| \`anime.list\` | 订阅列表 | 本群订阅的番剧 |
|
|
43
|
+
| \`anime.clear\` | 清空订阅 | 清空本群订阅 |
|
|
44
|
+
| \`anime.next [天数]\` | 追番计划 | 订阅作品未来排期 |
|
|
45
|
+
| \`anime.on\` / \`anime.off\` | 开启推送 / 关闭推送 | 本群定时推送开关 |
|
|
46
|
+
| \`anime.status\` | 推送状态 | 本群推送状态 |
|
|
47
|
+
| \`anime.push <类型>\` | 立即推送 | 手动发一次推送 |
|
|
48
|
+
|
|
49
|
+
每日、深夜档与本周只列还在播的作品,本季表收录该季首播的全部 TV / WEB 动画。
|
|
50
|
+
卡片上的 \`ID:xxx\` 可直接用于订阅。
|
|
51
|
+
|
|
52
|
+
若无法获取封面图和评分,请尝试配置 proxy-agent 代理设置。
|
|
52
53
|
`;
|
|
53
54
|
const logger = new koishi_1.Logger(constants_1.PLUGIN_NAME);
|
|
54
55
|
function apply(ctx, rawConfig) {
|
|
@@ -98,6 +99,8 @@ function apply(ctx, rawConfig) {
|
|
|
98
99
|
const digest = new digest_1.DigestService(ctx, config, logger, index, airing);
|
|
99
100
|
const renderer = new image_1.Renderer(ctx, config, logger, http);
|
|
100
101
|
const guard = new dispatch_1.DispatchGuard(ctx, logger);
|
|
102
|
+
// 单部作品详情:只服务 `anime.info` 的交互查询,不参与推送
|
|
103
|
+
const infos = new info_1.InfoService(ctx, logger, http, digest.localizer, config.timeZone, (0, constants_1.getMessages)(config.locale));
|
|
101
104
|
// 立刻开始载入:读的是本地依赖包,通常几十毫秒;
|
|
102
105
|
// 与插件启动并行,指令和推送在构建前会 await 它,不会出现「索引还没好」的空窗。
|
|
103
106
|
const indexReady = index.ensure();
|
|
@@ -107,6 +110,7 @@ function apply(ctx, rawConfig) {
|
|
|
107
110
|
Promise.all([indexReady, localizeReady, airingReady]).catch(() => undefined);
|
|
108
111
|
const dispatcher = new dispatch_1.Dispatcher(ctx, config, logger, (payload) => renderer.render({
|
|
109
112
|
kind: payload.kind,
|
|
113
|
+
now: payload.generatedAt,
|
|
110
114
|
header: payload.header,
|
|
111
115
|
summary: payload.summary,
|
|
112
116
|
groups: payload.groups,
|
|
@@ -121,7 +125,7 @@ function apply(ctx, rawConfig) {
|
|
|
121
125
|
logger.debug('[lifecycle] 清理已完结订阅失败: %s', (0, utils_1.errorMessage)(error));
|
|
122
126
|
});
|
|
123
127
|
});
|
|
124
|
-
const deps = { ctx, config, logger, digest, renderer };
|
|
128
|
+
const deps = { ctx, config, logger, digest, renderer, info: infos };
|
|
125
129
|
registerCommands(ctx, deps);
|
|
126
130
|
ctx.on('ready', async () => {
|
|
127
131
|
try {
|
|
@@ -221,6 +225,18 @@ function registerCommands(ctx, deps) {
|
|
|
221
225
|
return;
|
|
222
226
|
return (0, handlers_1.commandSearch)(deps, session, keyword ?? '', { limit: options?.limit });
|
|
223
227
|
});
|
|
228
|
+
cmd.subcommand('.info <target:string>', '查看番剧详情')
|
|
229
|
+
.alias('番剧详情', '番剧信息', '详情')
|
|
230
|
+
.usage('参数是 Bangumi 条目 ID,或 `anime.search` 结果中的序号,也可以直接给标题关键词。'
|
|
231
|
+
+ '以图片推送,渲染失败或设置为文本模式时改成文字。')
|
|
232
|
+
.example('anime.info 400602 # 葬送的芙莉莲')
|
|
233
|
+
.example('anime.info 3 # 搜索结果里的第 3 条')
|
|
234
|
+
.example('anime.info 芙莉莲 # 直接按标题查')
|
|
235
|
+
.action(async ({ session }, target) => {
|
|
236
|
+
if (!session)
|
|
237
|
+
return;
|
|
238
|
+
return (0, handlers_1.commandInfo)(deps, session, target ?? '');
|
|
239
|
+
});
|
|
224
240
|
cmd.subcommand('.sub <target:string>', '订阅一部番剧')
|
|
225
241
|
.alias('番剧订阅', '订阅番剧', '订阅添加', '添加订阅')
|
|
226
242
|
.usage('参数是 Bangumi 条目 ID(季表卡片上的 `ID:253104`),也可以是 `anime.search` 结果中的序号。')
|
package/lib/render/covers.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Canvas } from '@napi-rs/canvas';
|
|
2
2
|
import type { Context, Logger } from 'koishi';
|
|
3
|
-
import type {
|
|
3
|
+
import type { CoverRequest } from '../types';
|
|
4
4
|
import { HttpClient } from '../services/network';
|
|
5
5
|
/**
|
|
6
6
|
* 封面加载器。
|
|
@@ -29,7 +29,7 @@ export declare class CoverStore {
|
|
|
29
29
|
*
|
|
30
30
|
* `limit` 为 0 表示**不限制**,与「不加载」含义相反,因此早退判断只看 `items` 是否为空。
|
|
31
31
|
*/
|
|
32
|
-
load(items:
|
|
32
|
+
load(items: CoverRequest[], limit: number): Promise<Map<string, Canvas | null>>;
|
|
33
33
|
/** 单个封面:内存缓存 → 磁盘缓存 → 网络 */
|
|
34
34
|
private loadOne;
|
|
35
35
|
private fetchAndDecode;
|
package/lib/render/image.d.ts
CHANGED
|
@@ -2,22 +2,7 @@ import { HttpClient } from '../services/network';
|
|
|
2
2
|
import type { Context, Logger } from 'koishi';
|
|
3
3
|
import type { Config } from '../config';
|
|
4
4
|
import type { RenderPayload } from '../types';
|
|
5
|
-
|
|
6
|
-
* 风格化图片渲染器。
|
|
7
|
-
*
|
|
8
|
-
* 四种版面共用同一套版式规则:页眉是标题 + 副标题 + 统计 + 时区,页脚是数据来源;
|
|
9
|
-
* 卡片文字顺序与配色统一为「标题 → 时间 → `ID:xxx` → 评分」。
|
|
10
|
-
*
|
|
11
|
-
* | 版面 | 用于 |
|
|
12
|
-
* | :--- | :--- |
|
|
13
|
-
* | 单日时间轴 | 每日 / 深夜档 |
|
|
14
|
-
* | 七日周历 | 本周 |
|
|
15
|
-
* | 卡片列表 | `output.layout = list` |
|
|
16
|
-
* | 封面网格 | 本季,固定每行 10 部 |
|
|
17
|
-
*
|
|
18
|
-
* 封面统一按 `1 : 1.4` 等比完整显示,文字块的竖直位置只由版式常量决定,
|
|
19
|
-
* 因此同一版面的每一条都对齐在同一条基线上。
|
|
20
|
-
*/
|
|
5
|
+
import type { SubjectInfo } from '../services/info';
|
|
21
6
|
export declare class Renderer {
|
|
22
7
|
private config;
|
|
23
8
|
private logger;
|
|
@@ -30,6 +15,13 @@ export declare class Renderer {
|
|
|
30
15
|
init(): void;
|
|
31
16
|
private get messages();
|
|
32
17
|
render(payload: RenderPayload): Promise<Buffer>;
|
|
18
|
+
/**
|
|
19
|
+
* 渲染单部作品的详情卡(`anime.info`)。
|
|
20
|
+
*
|
|
21
|
+
* 与推送表共用同一份封面缓存与字体,但排版完全独立——见 `render/info.ts`。
|
|
22
|
+
* 封面单独取一张,不复用推送表的批量加载。
|
|
23
|
+
*/
|
|
24
|
+
renderInfo(info: SubjectInfo): Promise<Buffer>;
|
|
33
25
|
private draw;
|
|
34
26
|
/** 空白画布:统一底色 */
|
|
35
27
|
private createSurface;
|
|
@@ -72,6 +64,16 @@ export declare class Renderer {
|
|
|
72
64
|
* 跨过午夜时在轨道上插一个日期节点,读者不会把凌晨场次误认成当天上午。
|
|
73
65
|
*/
|
|
74
66
|
private drawTimeline;
|
|
67
|
+
/**
|
|
68
|
+
* 时间轴上的「现在」分界线。
|
|
69
|
+
*
|
|
70
|
+
* 画成一条横贯的虚线 + 右端的时刻,读者一眼就能看出上下哪边已经播了。
|
|
71
|
+
* 参考 bangumi.online 的播出时间轴:没有这条线时,`00:00` 的场次和 `23:00` 的场次
|
|
72
|
+
* 长得一模一样,用户得自己对表才知道现在几点。
|
|
73
|
+
*
|
|
74
|
+
* @returns 画完之后的新光标位置(线自己占一段高度)
|
|
75
|
+
*/
|
|
76
|
+
private drawNowMarker;
|
|
75
77
|
/** 时间轴上的日期节点:圆点 + 日期 + 条目数 */
|
|
76
78
|
private drawTimelineDayHeader;
|
|
77
79
|
/** 时间轴主线,首条之上不画线 */
|
package/lib/render/image.js
CHANGED
|
@@ -6,6 +6,7 @@ const constants_1 = require("../constants");
|
|
|
6
6
|
const fonts_1 = require("./fonts");
|
|
7
7
|
const covers_1 = require("./covers");
|
|
8
8
|
const time_1 = require("../utils/time");
|
|
9
|
+
const info_1 = require("./info");
|
|
9
10
|
const canvas_2 = require("./canvas");
|
|
10
11
|
/**
|
|
11
12
|
* 风格化图片渲染器。
|
|
@@ -23,6 +24,8 @@ const canvas_2 = require("./canvas");
|
|
|
23
24
|
* 封面统一按 `1 : 1.4` 等比完整显示,文字块的竖直位置只由版式常量决定,
|
|
24
25
|
* 因此同一版面的每一条都对齐在同一条基线上。
|
|
25
26
|
*/
|
|
27
|
+
/** 「现在」分界线占用的高度,与 drawNowMarker 保持一致 */
|
|
28
|
+
const NOW_MARKER_HEIGHT = 26;
|
|
26
29
|
class Renderer {
|
|
27
30
|
config;
|
|
28
31
|
logger;
|
|
@@ -54,6 +57,29 @@ class Renderer {
|
|
|
54
57
|
this.logger.debug('[render] %s 出图完成 (%dms),尺寸 %dx%d,%d bytes (jpeg q=%d)', payload.kind, Date.now() - started, canvas.width, canvas.height, encoded.length, this.config.render.jpegQuality);
|
|
55
58
|
return encoded;
|
|
56
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* 渲染单部作品的详情卡(`anime.info`)。
|
|
62
|
+
*
|
|
63
|
+
* 与推送表共用同一份封面缓存与字体,但排版完全独立——见 `render/info.ts`。
|
|
64
|
+
* 封面单独取一张,不复用推送表的批量加载。
|
|
65
|
+
*/
|
|
66
|
+
async renderInfo(info) {
|
|
67
|
+
this.init();
|
|
68
|
+
const started = Date.now();
|
|
69
|
+
const covers = await this.covers.load([info], 1);
|
|
70
|
+
const cover = covers.get(info.coverUrl) ?? null;
|
|
71
|
+
const card = (0, info_1.renderInfoCard)({
|
|
72
|
+
info,
|
|
73
|
+
messages: this.messages,
|
|
74
|
+
cover,
|
|
75
|
+
footer: this.messages.infoUpdated((0, time_1.formatMoment)(Date.now(), this.config.timeZone, this.messages.weekdayNames)),
|
|
76
|
+
});
|
|
77
|
+
const canvas = (0, canvas_1.createCanvas)(card.width, card.height);
|
|
78
|
+
card.draw(canvas.getContext('2d'));
|
|
79
|
+
const encoded = await canvas.encode('jpeg', this.config.render.jpegQuality);
|
|
80
|
+
this.logger.debug('[render] info 出图完成 (%dms),尺寸 %dx%d,%d bytes', Date.now() - started, card.width, card.height, encoded.length);
|
|
81
|
+
return encoded;
|
|
82
|
+
}
|
|
57
83
|
async draw(payload) {
|
|
58
84
|
if (payload.kind === 'season' && this.config.output.layout !== 'list')
|
|
59
85
|
return this.drawSeason(payload);
|
|
@@ -198,21 +224,32 @@ class Renderer {
|
|
|
198
224
|
const width = L.timelineWidth;
|
|
199
225
|
// 只有一组时不画日期节点,高度按实际结构计算
|
|
200
226
|
const showDayHeader = groups.length > 1;
|
|
227
|
+
// 「现在」分界线自己占一段高度,只有单日/深夜档会画
|
|
228
|
+
const showNow = payload.kind !== 'weekly';
|
|
201
229
|
const height = L.headerHeight
|
|
202
230
|
+ all.length * (L.rowHeight + L.rowGap)
|
|
203
231
|
+ (showDayHeader ? groups.length * L.timelineDayHeaderHeight : 0)
|
|
232
|
+
+ (showNow ? NOW_MARKER_HEIGHT : 0)
|
|
204
233
|
+ L.footerHeight;
|
|
205
234
|
const { canvas, ctx } = this.createSurface(width, height);
|
|
206
235
|
this.drawHeader(ctx, payload, width);
|
|
207
236
|
const spineX = L.padding + L.timelineSpine;
|
|
208
237
|
let cursor = L.headerHeight + L.rowGap;
|
|
209
238
|
let index = 0;
|
|
239
|
+
let nowDrawn = false;
|
|
210
240
|
for (const group of groups) {
|
|
211
241
|
if (showDayHeader) {
|
|
212
242
|
this.drawTimelineDayHeader(ctx, group, spineX, cursor, width - L.padding - spineX);
|
|
213
243
|
cursor += L.timelineDayHeaderHeight;
|
|
214
244
|
}
|
|
215
245
|
for (const item of group.items) {
|
|
246
|
+
// 画在第一条尚未播出的条目之前;全部已播时画在末尾。
|
|
247
|
+
// 插入前先把光标下移半个行距,让虚线落在「上一条卡片底」与「下一条卡片顶」的正中,
|
|
248
|
+
// 而不是紧贴上一条。标记自带的高度正好抵掉这半个行距,总高因此不必重算。
|
|
249
|
+
if (showNow && !nowDrawn && item.atMs > payload.now) {
|
|
250
|
+
nowDrawn = true;
|
|
251
|
+
cursor = this.drawNowMarker(ctx, payload.now, spineX, cursor + L.rowGap / 2, width);
|
|
252
|
+
}
|
|
216
253
|
const y = cursor;
|
|
217
254
|
this.drawTimelineSpine(ctx, spineX, y - L.rowGap, y + L.rowHeight, index === 0);
|
|
218
255
|
this.drawTimelineEntry(ctx, item, covers.get(item.coverUrl) ?? null, spineX, y, width, night);
|
|
@@ -220,9 +257,56 @@ class Renderer {
|
|
|
220
257
|
index++;
|
|
221
258
|
}
|
|
222
259
|
}
|
|
260
|
+
if (showNow && !nowDrawn) {
|
|
261
|
+
this.drawNowMarker(ctx, payload.now, spineX, cursor, width);
|
|
262
|
+
}
|
|
223
263
|
this.drawFooter(ctx, payload, width, height);
|
|
224
264
|
return canvas;
|
|
225
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* 时间轴上的「现在」分界线。
|
|
268
|
+
*
|
|
269
|
+
* 画成一条横贯的虚线 + 右端的时刻,读者一眼就能看出上下哪边已经播了。
|
|
270
|
+
* 参考 bangumi.online 的播出时间轴:没有这条线时,`00:00` 的场次和 `23:00` 的场次
|
|
271
|
+
* 长得一模一样,用户得自己对表才知道现在几点。
|
|
272
|
+
*
|
|
273
|
+
* @returns 画完之后的新光标位置(线自己占一段高度)
|
|
274
|
+
*/
|
|
275
|
+
drawNowMarker(ctx, now, spineX, y, width) {
|
|
276
|
+
const L = constants_1.IMAGE_LAYOUT;
|
|
277
|
+
const height = NOW_MARKER_HEIGHT;
|
|
278
|
+
const centreY = y + height / 2;
|
|
279
|
+
// 左端圆点:与时间轴的圆点同一尺寸,视觉上属于同一条轴
|
|
280
|
+
ctx.beginPath();
|
|
281
|
+
ctx.arc(spineX, centreY, 5, 0, Math.PI * 2);
|
|
282
|
+
ctx.fillStyle = canvas_2.THEME.accent;
|
|
283
|
+
ctx.fill();
|
|
284
|
+
ctx.lineWidth = 2.5;
|
|
285
|
+
ctx.strokeStyle = canvas_2.THEME.surface;
|
|
286
|
+
ctx.stroke();
|
|
287
|
+
const left = spineX + 14;
|
|
288
|
+
const right = width - L.padding;
|
|
289
|
+
ctx.save();
|
|
290
|
+
ctx.setLineDash([4, 4]);
|
|
291
|
+
ctx.lineWidth = 1.5;
|
|
292
|
+
ctx.strokeStyle = canvas_2.THEME.accent;
|
|
293
|
+
ctx.beginPath();
|
|
294
|
+
ctx.moveTo(left, centreY);
|
|
295
|
+
ctx.lineTo(right, centreY);
|
|
296
|
+
ctx.stroke();
|
|
297
|
+
ctx.restore();
|
|
298
|
+
// 时刻压在虚线上、靠右,底色垫一层让线条不穿过文字
|
|
299
|
+
const label = this.messages.nowLabel((0, time_1.formatClock)(now, this.config.timeZone));
|
|
300
|
+
ctx.font = (0, fonts_1.font)(fonts_1.FONT_SIZE.cardMeta, true);
|
|
301
|
+
const textWidth = ctx.measureText(label).width;
|
|
302
|
+
const boxX = right - textWidth - 10;
|
|
303
|
+
(0, canvas_2.fillRoundRect)(ctx, boxX, centreY - 9, textWidth + 10, 18, 9, canvas_2.THEME.surface);
|
|
304
|
+
ctx.fillStyle = canvas_2.THEME.accent;
|
|
305
|
+
ctx.textAlign = 'left';
|
|
306
|
+
ctx.textBaseline = 'middle';
|
|
307
|
+
ctx.fillText(label, boxX + 5, centreY + 0.5);
|
|
308
|
+
return y + height;
|
|
309
|
+
}
|
|
226
310
|
/** 时间轴上的日期节点:圆点 + 日期 + 条目数 */
|
|
227
311
|
drawTimelineDayHeader(ctx, group, spineX, y, width) {
|
|
228
312
|
const L = constants_1.IMAGE_LAYOUT;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SKRSContext2D } from '@napi-rs/canvas';
|
|
2
|
+
import type { SubjectInfo } from '../services/info';
|
|
3
|
+
import type { Messages } from '../constants';
|
|
4
|
+
/** 卡片需要的全部素材 */
|
|
5
|
+
export interface InfoCard {
|
|
6
|
+
info: SubjectInfo;
|
|
7
|
+
messages: Messages;
|
|
8
|
+
cover: {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
} | null;
|
|
12
|
+
/** 页脚的时间戳文案,如 `数据更新于 2026/10/12 20:00` */
|
|
13
|
+
footer: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 作品详情卡。
|
|
17
|
+
*
|
|
18
|
+
* ## 布局
|
|
19
|
+
*
|
|
20
|
+
* ```
|
|
21
|
+
* ┌────────┬──────────────────────────────────────┐
|
|
22
|
+
* │ │ 葬送的芙莉莲 [已订阅] │
|
|
23
|
+
* │ 封面 │ 葬送のフリーレン │
|
|
24
|
+
* │ │ ★ 8.52 36553 人评分 │
|
|
25
|
+
* │ │ [TV] [2023/9/29周五] [已完结] │
|
|
26
|
+
* ├────────┴──────────────────────────────────────┤
|
|
27
|
+
* │ 简介 │
|
|
28
|
+
* │ 魔法使芙莉莲和勇者辛美尔…… │
|
|
29
|
+
* ├───────────────────┬───────────────────────────┤
|
|
30
|
+
* │ 放送进度 28/28 │ 评分分布 │
|
|
31
|
+
* │ ①②③④⑤⑥… │ ▂▅▇▆▃ │
|
|
32
|
+
* └───────────────────┴───────────────────────────┘
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* 几个刻意的取舍:
|
|
36
|
+
*
|
|
37
|
+
* - **右栏有多少内容算多少**,不钉死高度——卡片整体高度由封面与右栏较高者决定。
|
|
38
|
+
* 参考图那版右栏下方留了一大片空白,是因为把右栏高度也写死了。
|
|
39
|
+
* - **逐集进度用带底色的方格**而不是大圆角按钮:一季 12 话只需一行,半年番 24 话两行,
|
|
40
|
+
* 纵向占用少很多。已播用强调色实心,未播用浅灰。
|
|
41
|
+
* - **评分分布与逐集进度并排**,共用 `bandHeight` 这条基线,省掉一个垂直区块。
|
|
42
|
+
* - 话数超过 `epGridMax`(长期连载番)时**不铺格子**,只给一行 `已播 N 话` 文案,
|
|
43
|
+
* 否则 2852 话会画出上百行。
|
|
44
|
+
*/
|
|
45
|
+
export declare function renderInfoCard(card: InfoCard): {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
draw: (ctx: SKRSContext2D) => void;
|
|
49
|
+
};
|