koishi-plugin-anime-schedule 1.0.1 → 1.0.3

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/handlers.js CHANGED
@@ -406,16 +406,18 @@ async function commandNext(deps, session, daysText) {
406
406
  const airings = await digest.upcomingAirings(rows.map((row) => row.tid), days);
407
407
  if (!airings.length)
408
408
  return `未来 ${days} 天内订阅的作品没有播出计划。`;
409
- const weekdayNames = (0, constants_1.getMessages)(config.locale).weekdayNames;
409
+ const messages = (0, constants_1.getMessages)(config.locale);
410
410
  const lines = [];
411
411
  let currentDate = '';
412
412
  for (const item of airings) {
413
413
  if (item.date !== currentDate) {
414
414
  currentDate = item.date;
415
- const weekday = weekdayNames[(0, time_1.getLocalWeekday)((0, time_1.getDayStartMs)(item.date, digest.timeZone), digest.timeZone)] ?? '';
416
- lines.push(`【${item.date} ${weekday}】`.trimEnd());
415
+ const weekday = messages.weekdayNames[(0, time_1.getLocalWeekday)((0, time_1.getDayStartMs)(item.date, digest.timeZone), digest.timeZone)] ?? '';
416
+ // 分组标题前留一个空行,与推送表的分组观感一致
417
+ lines.push(`${lines.length ? '\n' : ''}【${item.date} ${weekday}】`.trimEnd());
417
418
  }
418
- lines.push(`· ${item.time} ${(0, utils_1.truncate)(item.name, 60)} (${item.episode})`);
419
+ const episode = item.recurring && item.episodeCount > 0 ? messages.episodeUnit(item.episodeCount) : '';
420
+ lines.push(`${episode ? `${item.time} ${episode}` : item.time}\n ${(0, utils_1.truncate)(item.name, 60)}`);
419
421
  }
420
422
  return [`未来 ${days} 天内订阅作品的播出计划:`, ...lines].join('\n');
421
423
  }
@@ -1,3 +1,42 @@
1
+ /**
2
+ * 纯文本形态的渲染。
3
+ *
4
+ * 与图片形态共用同一份 `Digest`,因此两者列出的作品、时刻、集数完全一致;
5
+ * 差别只在**信息的排布**上——图片能靠颜色与留白分区,文本只能靠符号与缩进。
6
+ *
7
+ * ## 版式约定
8
+ *
9
+ * ```
10
+ * 今日番剧更新 (10/1周四)
11
+ * 共 7 部作品,其中 3 部为深夜档
12
+ *
13
+ * 【今日播出】
14
+ * 00:00 第 53 话
15
+ * 考拉绘日记
16
+ * ID:564980 评分 5.0
17
+ *
18
+ * 00:15 第 12 话
19
+ * 盗墓王
20
+ * ID:621835 评分 4.5
21
+ * ```
22
+ *
23
+ * 四条规则:
24
+ *
25
+ * 1. **一行放一个语义块**。时刻与集数留在首行(信息最密、最需要对齐),标题单独占一行
26
+ * 并缩进两格——中文标题动辄二三十字,和时刻挤在一行必然折行,折行后时刻就淹没在
27
+ * 文字里了。ID 与评分再缩进一层,便于扫读时跳过。
28
+ * 2. **集数走 `episodeUnit`**(`第 53 话`),不再用 `#53`。整个界面的其它地方都是中文,
29
+ * `#` 是英文记法,混在一起不好读。
30
+ * 3. **一次性放送不显示集数**:剧场版、特别篇没有「第几话」的概念,`episodeCount` 恒为 1,
31
+ * 写出来是噪声。这与图片卡片的处理保持一致。
32
+ * 4. **条目之间空一行**。三条一组的块状结构如果首尾相连,读者分不清哪一行属于哪一部;
33
+ * 空行是最省字符的分隔手段,一屏仍能放下四五部。
34
+ *
35
+ * 季表用同一套版式,只把首行的时刻换成**完整日期**(`7/7周二 23:00`)——它是一览,
36
+ * 读者最先想知道「什么时候开播」,而条目散在整个季度里,光有时刻没法定位。相应地季表
37
+ * **不套分组标题**:日期已经在首行,再套一层 `【10/11周日】` 是重复信息,还会出现
38
+ * 「首播在 9/27 的条目落在 `【10/2周五】` 组里」(因为它下次播出在那天)这种对不上的情况。
39
+ */
1
40
  import { h } from 'koishi';
2
41
  import type { Config } from '../config';
3
42
  import type { Digest } from '../types';
@@ -5,7 +44,7 @@ import type { Digest } from '../types';
5
44
  * 文本形态的推送渲染。
6
45
  *
7
46
  * 返回消息元素数组,调用方按平台能力选择直接发送或合并转发:
8
- * 第一项是列表头 + 统计行与数据来源,其余每一项是一个分组。
47
+ * 第一项是列表头 + 统计行,之后每一项是一个分组,最后是数据来源。
9
48
  * 相邻元素之间插入换行,否则平台会把它们首尾拼在同一行。
10
49
  */
11
50
  export declare function renderText(config: Config, digest: Digest): h[];
@@ -2,25 +2,102 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderText = renderText;
4
4
  exports.buildForward = buildForward;
5
+ /**
6
+ * 纯文本形态的渲染。
7
+ *
8
+ * 与图片形态共用同一份 `Digest`,因此两者列出的作品、时刻、集数完全一致;
9
+ * 差别只在**信息的排布**上——图片能靠颜色与留白分区,文本只能靠符号与缩进。
10
+ *
11
+ * ## 版式约定
12
+ *
13
+ * ```
14
+ * 今日番剧更新 (10/1周四)
15
+ * 共 7 部作品,其中 3 部为深夜档
16
+ *
17
+ * 【今日播出】
18
+ * 00:00 第 53 话
19
+ * 考拉绘日记
20
+ * ID:564980 评分 5.0
21
+ *
22
+ * 00:15 第 12 话
23
+ * 盗墓王
24
+ * ID:621835 评分 4.5
25
+ * ```
26
+ *
27
+ * 四条规则:
28
+ *
29
+ * 1. **一行放一个语义块**。时刻与集数留在首行(信息最密、最需要对齐),标题单独占一行
30
+ * 并缩进两格——中文标题动辄二三十字,和时刻挤在一行必然折行,折行后时刻就淹没在
31
+ * 文字里了。ID 与评分再缩进一层,便于扫读时跳过。
32
+ * 2. **集数走 `episodeUnit`**(`第 53 话`),不再用 `#53`。整个界面的其它地方都是中文,
33
+ * `#` 是英文记法,混在一起不好读。
34
+ * 3. **一次性放送不显示集数**:剧场版、特别篇没有「第几话」的概念,`episodeCount` 恒为 1,
35
+ * 写出来是噪声。这与图片卡片的处理保持一致。
36
+ * 4. **条目之间空一行**。三条一组的块状结构如果首尾相连,读者分不清哪一行属于哪一部;
37
+ * 空行是最省字符的分隔手段,一屏仍能放下四五部。
38
+ *
39
+ * 季表用同一套版式,只把首行的时刻换成**完整日期**(`7/7周二 23:00`)——它是一览,
40
+ * 读者最先想知道「什么时候开播」,而条目散在整个季度里,光有时刻没法定位。相应地季表
41
+ * **不套分组标题**:日期已经在首行,再套一层 `【10/11周日】` 是重复信息,还会出现
42
+ * 「首播在 9/27 的条目落在 `【10/2周五】` 组里」(因为它下次播出在那天)这种对不上的情况。
43
+ */
5
44
  const koishi_1 = require("koishi");
6
45
  const constants_1 = require("../constants");
7
46
  const utils_1 = require("../utils");
47
+ /** 标题最多显示多少个字,超出补省略号(与命令里 `anime.next` 的宽度保持一致) */
48
+ const TITLE_MAX = 60;
49
+ function formatItem(item, season, config) {
50
+ const messages = (0, constants_1.getMessages)(config.locale);
51
+ const episode = item.recurring && item.episodeCount > 0 ? messages.episodeUnit(item.episodeCount) : '';
52
+ // 季表是一览,读者最先想知道「什么时候开播」,因此首行给完整日期;其余三张表按天分组,
53
+ // 日期已经在分组标题上,首行只留时刻。
54
+ const lead = season ? (item.airLabel || item.time) : item.time;
55
+ const lines = [episode ? `${lead} ${episode}` : lead, ` ${(0, utils_1.truncate)(item.name, TITLE_MAX)}`];
56
+ const parts = [];
57
+ if (item.bgmId !== null)
58
+ parts.push(`ID:${item.bgmId}`);
59
+ if (item.score)
60
+ parts.push(`评分 ${item.score}`);
61
+ if (item.subscribed)
62
+ parts.push(messages.subscribedBadge);
63
+ if (parts.length)
64
+ lines.push(` ${parts.join(' ')}`);
65
+ return lines.join('\n');
66
+ }
67
+ /**
68
+ * 分组。
69
+ *
70
+ * 季表**不再按天分组**:它的条目本来就散在整个季度里,而首行已经带了完整日期,
71
+ * 再套一层 `【10/11周日】` 是重复信息,还会让标签与条目日期对不上(如首播在 9/27 的
72
+ * 条目落在 `【10/2周五】` 组里,因为它「下次播出」在那天)。
73
+ */
74
+ function formatGroup(group, config, season) {
75
+ const body = group.items.map((item) => formatItem(item, season, config)).join('\n\n');
76
+ if (season || !group.label)
77
+ return body;
78
+ return `【${group.label}】\n${body}`;
79
+ }
8
80
  /**
9
81
  * 文本形态的推送渲染。
10
82
  *
11
83
  * 返回消息元素数组,调用方按平台能力选择直接发送或合并转发:
12
- * 第一项是列表头 + 统计行与数据来源,其余每一项是一个分组。
84
+ * 第一项是列表头 + 统计行,之后每一项是一个分组,最后是数据来源。
13
85
  * 相邻元素之间插入换行,否则平台会把它们首尾拼在同一行。
14
86
  */
15
87
  function renderText(config, digest) {
16
- const footer = (0, constants_1.getMessages)(config.locale).footer;
17
- const head = [digest.header, digest.summary, footer].filter(Boolean).join('\n');
18
- if (!digest.groups.length)
19
- return [koishi_1.h.text(head)];
20
- const elements = [koishi_1.h.text(head)];
88
+ const messages = (0, constants_1.getMessages)(config.locale);
89
+ const season = digest.kind === 'season';
90
+ if (!digest.groups.length) {
91
+ return [koishi_1.h.text([digest.header, digest.summary, messages.footer].filter(Boolean).join('\n'))];
92
+ }
93
+ const elements = [
94
+ koishi_1.h.text([digest.header, digest.summary].filter(Boolean).join('\n')),
95
+ ];
21
96
  for (const group of digest.groups) {
22
- elements.push(koishi_1.h.text(`\n${formatGroup(group, config)}`));
97
+ elements.push(koishi_1.h.text(`\n${formatGroup(group, config, season)}`));
23
98
  }
99
+ if (messages.footer)
100
+ elements.push(koishi_1.h.text(`\n${messages.footer}`));
24
101
  return elements;
25
102
  }
26
103
  /**
@@ -30,28 +107,15 @@ function renderText(config, digest) {
30
107
  * 平台或适配器不支持 `message.forward` 时由调用方退回普通文本。
31
108
  */
32
109
  function buildForward(config, digest) {
33
- const messages = [
110
+ const messages = (0, constants_1.getMessages)(config.locale);
111
+ const season = digest.kind === 'season';
112
+ const nodes = [
34
113
  (0, koishi_1.h)('message', { nickname: constants_1.DIGEST_LABELS[digest.kind] }, koishi_1.h.text([digest.header, digest.summary].filter(Boolean).join('\n'))),
35
114
  ];
36
115
  for (const group of digest.groups) {
37
- messages.push((0, koishi_1.h)('message', { nickname: group.label || digest.header }, koishi_1.h.text(formatGroup(group, config))));
116
+ nodes.push((0, koishi_1.h)('message', { nickname: group.label || digest.header }, koishi_1.h.text(formatGroup(group, config, season))));
38
117
  }
39
- const footer = (0, constants_1.getMessages)(config.locale).footer;
40
- if (footer)
41
- messages.push((0, koishi_1.h)('message', { nickname: '数据来源' }, koishi_1.h.text(footer)));
42
- return (0, koishi_1.h)('figure', messages);
43
- }
44
- function formatGroup(group, config) {
45
- const lines = [];
46
- if (group.label)
47
- lines.push(`【${group.label}】`);
48
- for (const item of group.items)
49
- lines.push(formatItem(item, config));
50
- return lines.join('\n');
51
- }
52
- function formatItem(item, config) {
53
- void config;
54
- const score = item.score ? ` [${item.score}]` : '';
55
- const id = item.bgmId === null ? '' : ` ID:${item.bgmId}`;
56
- return `· ${(0, utils_1.truncate)(`${item.time} ${item.name} (${item.episode})${score}${id}`, 140)}`;
118
+ if (messages.footer)
119
+ nodes.push((0, koishi_1.h)('message', { nickname: '数据来源' }, koishi_1.h.text(messages.footer)));
120
+ return (0, koishi_1.h)('figure', nodes);
57
121
  }
@@ -65,25 +65,19 @@ export declare class DigestService {
65
65
  * 这类作品(海螺小姐、樱桃小丸子、宝可梦 地平线、巧虎、战斗陀螺 X…)确实每周都在播,
66
66
  * 收录它们并不算错,但很多人不把它们当作「本季新番」,任其出现会让表格变得嘈杂。
67
67
  *
68
- * AniList **没有**「长期番」专用标记,只能靠「话数失控」这个间接特征。判定分两种:
68
+ * 判据只有一条:**展示话数 ≥ 阈值**。长期连载番的本集数自然会涨得很高,而一季 12~24
69
+ * 话的当季番永远达不到阈值,因此不需要再引入别的条件。
69
70
  *
70
- * 判据是 **`episodes` 为空**:AniList 对**有确定话数**的作品都会填 `episodes`,只有
71
- * 「不确定何时完结」的无限连载番才一直留空。再叠加「展示话数 ≥ 阈值」,
72
- * 当季番因此永远不会命中。
71
+ * 早先这里还叠加了「AniList 未给出总话数(`episodes === null`)」作为前提,但那条实际
72
+ * 上没有生效:大部分条目在闸门 2 之前就被筛掉,取不到 AniList 状态,于是「有状态」
73
+ * 这个前提直接为假,该剔的长期番反而漏剔(实测 `キラキラADらっこちゃん` 第 239 话、
74
+ * `ギャビーのドールハウス` 第 131 话都因此留在表里)。去掉它之后判据不再依赖网络,
75
+ * 行为更可预测,也不会再被限流影响。
73
76
  *
74
77
  * **必须在条目构建之后调用**:`applyAiringSchedule` 内部会按作品去重,去重前后的
75
78
  * 「第几话」可能不同。判据要落在**最终展示的话数**上。
76
79
  *
77
80
  * 阈值由 `content.longRunningThreshold` 决定,**默认 0 表示不过滤**。
78
- *
79
- * ## 已知覆盖不到的几类(刻意保守,不做猜测)
80
- *
81
- * - **没有 `aniListId`**:拿不到状态,无法判定(如 `ギャビーのドールハウス`,第 131 话)。
82
- * - **连 Bangumi 条目 ID 都没有**:连索引条目都取不到(如 `キラキラADらっこちゃん`,第 239 话)。
83
- * - **AniList 的总话数滞后**:AniList 只跟踪到某个「季度」就停更,`episodes` 停在较小的
84
- * 值而实际话数早已超过(实测 `コアラ絵日記`、`デジモンBEATBREAK` 都是 `episodes=49`
85
- * 但已播到第 53 话)。曾试过「展示话数 > episodes」这条补强,但无法稳定生效
86
- * (状态表在限流时可能缺项),因此不做——宁可漏剔也不误杀。
87
81
  */
88
82
  private dropLongRunning;
89
83
  /**
@@ -282,45 +282,28 @@ class DigestService {
282
282
  * 这类作品(海螺小姐、樱桃小丸子、宝可梦 地平线、巧虎、战斗陀螺 X…)确实每周都在播,
283
283
  * 收录它们并不算错,但很多人不把它们当作「本季新番」,任其出现会让表格变得嘈杂。
284
284
  *
285
- * AniList **没有**「长期番」专用标记,只能靠「话数失控」这个间接特征。判定分两种:
285
+ * 判据只有一条:**展示话数 ≥ 阈值**。长期连载番的本集数自然会涨得很高,而一季 12~24
286
+ * 话的当季番永远达不到阈值,因此不需要再引入别的条件。
286
287
  *
287
- * 判据是 **`episodes` 为空**:AniList 对**有确定话数**的作品都会填 `episodes`,只有
288
- * 「不确定何时完结」的无限连载番才一直留空。再叠加「展示话数 ≥ 阈值」,
289
- * 当季番因此永远不会命中。
288
+ * 早先这里还叠加了「AniList 未给出总话数(`episodes === null`)」作为前提,但那条实际
289
+ * 上没有生效:大部分条目在闸门 2 之前就被筛掉,取不到 AniList 状态,于是「有状态」
290
+ * 这个前提直接为假,该剔的长期番反而漏剔(实测 `キラキラADらっこちゃん` 第 239 话、
291
+ * `ギャビーのドールハウス` 第 131 话都因此留在表里)。去掉它之后判据不再依赖网络,
292
+ * 行为更可预测,也不会再被限流影响。
290
293
  *
291
294
  * **必须在条目构建之后调用**:`applyAiringSchedule` 内部会按作品去重,去重前后的
292
295
  * 「第几话」可能不同。判据要落在**最终展示的话数**上。
293
296
  *
294
297
  * 阈值由 `content.longRunningThreshold` 决定,**默认 0 表示不过滤**。
295
- *
296
- * ## 已知覆盖不到的几类(刻意保守,不做猜测)
297
- *
298
- * - **没有 `aniListId`**:拿不到状态,无法判定(如 `ギャビーのドールハウス`,第 131 话)。
299
- * - **连 Bangumi 条目 ID 都没有**:连索引条目都取不到(如 `キラキラADらっこちゃん`,第 239 话)。
300
- * - **AniList 的总话数滞后**:AniList 只跟踪到某个「季度」就停更,`episodes` 停在较小的
301
- * 值而实际话数早已超过(实测 `コアラ絵日記`、`デジモンBEATBREAK` 都是 `episodes=49`
302
- * 但已播到第 53 话)。曾试过「展示话数 > episodes」这条补强,但无法稳定生效
303
- * (状态表在限流时可能缺项),因此不做——宁可漏剔也不误杀。
304
298
  */
305
- dropLongRunning(items, candidates, states) {
299
+ dropLongRunning(items) {
306
300
  const threshold = this.config.content.longRunningThreshold;
307
301
  if (!threshold || threshold <= 0)
308
302
  return items;
309
- // 条目只带 tid,这里按 tid 找回索引条目
310
- const entryOfTid = new Map();
311
- for (const candidate of candidates) {
312
- const tid = defaultTidOf(candidate.entry) ?? candidate.entry.key;
313
- if (!entryOfTid.has(tid))
314
- entryOfTid.set(tid, candidate.entry);
315
- }
316
303
  const kept = [];
317
304
  const dropped = [];
318
305
  for (const item of items) {
319
- const entry = entryOfTid.get(item.tid);
320
- const state = entry ? this.airingStateOf(entry, states) : undefined;
321
- // 只有「AniList 明确不给总话数」才算无限连载
322
- const openEnded = state !== undefined && state.episodes === null;
323
- if (openEnded && item.episodeCount >= threshold) {
306
+ if (item.episodeCount >= threshold) {
324
307
  if (dropped.length < 12)
325
308
  dropped.push(`${item.name}(第${item.episodeCount}话)`);
326
309
  continue;
@@ -328,7 +311,7 @@ class DigestService {
328
311
  kept.push(item);
329
312
  }
330
313
  if (dropped.length) {
331
- (0, utils_1.debugLog)('[digest] 剔除超长期连载番 %d 部(话数失控且第 %d 话以上): %s', items.length - kept.length, threshold, dropped.join('、'));
314
+ (0, utils_1.debugLog)('[digest] 剔除超长期连载番 %d 部(第 %d 话以上): %s', items.length - kept.length, threshold, dropped.join('、'));
332
315
  }
333
316
  return kept;
334
317
  }
@@ -900,7 +883,7 @@ class DigestService {
900
883
  // 「第几话」可能不是同一个值(同一部作品在窗口内有多场时,保留的那一场未必是话数最大
901
884
  // 的那一场)。判据要落在**最终展示的话数**上,否则会出现「明明显示第 53 话却按第 49
902
885
  // 话判定」这种错位,导致该剔的没剔掉。
903
- const items = this.dropLongRunning(built, concluded, airingStates);
886
+ const items = this.dropLongRunning(built);
904
887
  return this.finish(kind, this.headerOf(kind, window), '', groupOverride ? groupOverride(items) : this.groupOf(kind, window, items), kind === 'sub' ? this.messages.noAiringSub(subscribed.size) : this.emptyTextOf(kind));
905
888
  }
906
889
  /**
@@ -253,14 +253,19 @@ class Scheduler {
253
253
  continue;
254
254
  }
255
255
  try {
256
+ // 与推送表同一套版式:首行时刻 + 集数,标题与提示各占一行
256
257
  const lines = fresh.map((item) => {
257
258
  const inMinutes = Math.max(0, Math.round((item.atMs - now) / 60000));
258
- return `· ${item.time} ${item.name} (${item.episode})\n 还有约 ${inMinutes} 分钟开播`;
259
+ const episode = item.recurring && item.episodeCount > 0
260
+ ? (0, constants_1.getMessages)(this.config.locale).episodeUnit(item.episodeCount)
261
+ : '';
262
+ const lead = episode ? `${item.time} ${episode}` : item.time;
263
+ return `${lead}\n ${item.name}\n 还有约 ${inMinutes} 分钟开播`;
259
264
  });
260
265
  const message = koishi_1.h.text([
261
266
  `即将开播 (${(0, time_1.formatDate)(now, this.config.timeZone)})`,
262
267
  ...lines,
263
- ].join('\n'));
268
+ ].join('\n\n'));
264
269
  const bot = this.ctx.bots.find((entry) => entry.selfId === channel.botId);
265
270
  if (bot)
266
271
  await bot.sendMessage(channel.channelId, message);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-anime-schedule",
3
3
  "description": "番剧更新表与订阅提醒:每日 / 深夜档 / 本周 / 本季四张表,支持单部订阅",
4
- "version": "1.0.1",
4
+ "version": "1.0.3",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
package/readme.md CHANGED
@@ -52,7 +52,7 @@ npm i koishi-plugin-anime-schedule
52
52
  | `push.dailyTime` 等 | 见配置页 | 四种推送各自的开关与时刻 |
53
53
  | `push.updateLookaheadMinutes` | `30` | 订阅作品提前多少分钟提醒 |
54
54
  | `content.maxItems` | `0` | 单次推送最多显示多少部,`0` 为不限制 |
55
- | `content.longRunningThreshold` | `0` | 剔除长期连载番的话数阈值,`0` 为不过滤。 |
55
+ | `content.longRunningThreshold` | `0` | 剔除长期连载番的话数阈值,`0` 为不过滤 |
56
56
  | `output.format` | `image` | `image` / `text` |
57
57
  | `output.layout` | `table` | 图片排版:`table` / `list` |
58
58
  | `localize.titleStyle` | `localized` | `localized` 仅中文 / `both` 中文(日文原名)/ `original` 仅日文原名 |