koishi-plugin-comic 1.0.7 → 1.0.9

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/index.js CHANGED
@@ -53,23 +53,21 @@ exports.usage = `
53
53
  <h3>命令列表</h3>
54
54
  <ul>
55
55
  <li><code>comic [关键词]</code> - 聚合搜索/直接下载漫画</li>
56
- <li><code>comic search &lt;关键词&gt;</code> - 聚合搜索漫画</li>
56
+ <li><code>comic search &lt;关键词/ID&gt;</code> - 聚合搜索漫画,各平台最多8条,合并转发并标注来源</li>
57
57
  <li><code>comic download &lt;ID&gt; [章节ID]</code> - 下载漫画PDF</li>
58
- <li><code>comic detail &lt;关键词/ID&gt;</code> - 查看漫画详情</li>
59
- <li><code>comic leaderboard [类型]</code> - 排行榜</li>
60
- <li><code>comic latest</code> - 最近更新</li>
61
- <li><code>comic random</code> - 随机推荐</li>
58
+ <li><code>comic detail &lt;关键词/ID&gt;</code> - 查看漫画详情,关键词模式下双平台各展示最相似结果</li>
59
+ <li><code>comic leaderboard [类型] [页码]</code> - 排行榜(类型: day/week/month/total,默认day),双平台分2条发送</li>
60
+ <li><code>comic latest [-n 数量]</code> - 最近更新,每平台默认10条(最多50),双平台分2条发送</li>
61
+ <li><code>comic random [-n 数量]</code> - 随机推荐,每平台默认5个(最多20),双平台分2条发送</li>
62
62
  </ul>
63
63
  `;
64
64
  exports.Config = koishi_1.Schema.object({
65
65
  apiBase: koishi_1.Schema.string()
66
66
  .description('comic-api 后端地址')
67
67
  .default('http://127.0.0.1:8699'),
68
- defaultSource: koishi_1.Schema.union(['jm', 'bika'])
69
- .description('默认漫画源')
70
- .default('jm'),
71
68
  concurrency: koishi_1.Schema.number()
72
- .description('下载并发数')
69
+ .min(1).max(16)
70
+ .description('下载并发数 (1-16)')
73
71
  .default(4),
74
72
  logInfo: koishi_1.Schema.boolean()
75
73
  .description('打印 API 调用日志')
@@ -82,13 +80,6 @@ exports.Config = koishi_1.Schema.object({
82
80
  fileSendPath: koishi_1.Schema.string()
83
81
  .description('PDF 发送方式为 file 时的本地中转保存目录')
84
82
  .default('/koishi/temp'),
85
- tool: koishi_1.Schema.object({
86
- enabled: koishi_1.Schema.boolean().default(true).description('开启后自动注册 ChatLuna 工具'),
87
- name: koishi_1.Schema.string().default('comic').description('工具名称'),
88
- description: koishi_1.Schema.string()
89
- .default('漫画搜索与下载工具。支持禁漫天堂(JM)和哔咔漫画(Bika)双平台。包含动作:search (搜索), detail (查看详情), leaderboard (排行榜), latest (最近更新), random (随机推荐), download (下载本子并发送)。提示:如果用户给出的本子名、角色名或关键词模糊、包含缩写或拼写不够精确,或者使用该工具搜索未返回结果,你必须先调用联网搜索工具检索获取该本子的准确正式名称、画师/作者或完整标题,然后再用精准的关键词调用此工具。')
90
- .description('工具描述'),
91
- }).description('ChatLuna 工具设置'),
92
83
  });
93
84
  function getPdfPassword(source, comicId, chapterId) {
94
85
  const src = source.trim().toLowerCase();
@@ -115,14 +106,6 @@ function determineSource(id) {
115
106
  }
116
107
  return null;
117
108
  }
118
- function formatComics(comics, sourceLabel = '') {
119
- if (!comics || comics.length === 0)
120
- return '没有找到相关漫画。';
121
- return comics.map((c, i) => {
122
- const src = c.source === 'jm' ? '禁漫天堂' : c.source === 'bika' ? '哔咔漫画' : sourceLabel;
123
- return `${i + 1}. [${src}] ${c.title} 作者:${c.author || '佚名'}`;
124
- }).join('\n');
125
- }
126
109
  function apply(ctx, config) {
127
110
  const logger = ctx.logger('comic');
128
111
  async function apiGet(path, params) {
@@ -134,9 +117,23 @@ function apply(ctx, config) {
134
117
  }
135
118
  if (config.logInfo)
136
119
  logger.info(`GET ${url}`);
137
- const res = await ctx.http.get(url);
138
- return res;
120
+ try {
121
+ const res = await ctx.http.get(url);
122
+ // 后端统一错误格式:{ success: false, error: '...' }
123
+ if (res && typeof res === 'object' && res.success === false) {
124
+ const msg = res.error || '后端返回未知错误';
125
+ logger.warn(`API 返回失败 [${url}]: ${msg}`);
126
+ throw new Error(msg);
127
+ }
128
+ return res;
129
+ }
130
+ catch (err) {
131
+ logger.error(`API 请求失败 [${url}]: ${err.message}`);
132
+ throw err;
133
+ }
139
134
  }
135
+ // 子命令关键词集合,用于父命令路由时排除(避免被当作搜索词/下载ID)
136
+ const SUBCOMMANDS = ['search', 'download', 'detail', 'leaderboard', 'latest', 'random'];
140
137
  // Parent command 'comic' routing logic
141
138
  ctx.command('comic [keyword:text]', '聚合漫画搜索与直接下载')
142
139
  .action(async ({ session }, keyword) => {
@@ -146,22 +143,31 @@ function apply(ctx, config) {
146
143
  return session.execute('help comic');
147
144
  }
148
145
  const clean = keyword.trim();
146
+ // 关键修复:当 keyword 以子命令名开头时,强制用点号形式转发到子命令。
147
+ // Koishi 的 `comic [keyword:text]` 贪婪参数会吞掉 `comic random` / `comic latest`
148
+ // 这类无参子命令,导致它们被当作搜索词处理。
149
+ const parts = clean.split(/\s+/);
150
+ const firstWord = parts[0].toLowerCase();
151
+ if (SUBCOMMANDS.includes(firstWord)) {
152
+ const rest = parts.slice(1).join(' ');
153
+ return session.execute(`comic.${firstWord}${rest ? ' ' + rest : ''}`);
154
+ }
149
155
  const resolved = determineSource(clean);
150
156
  if (resolved) {
151
157
  if (resolved.source === 'jm' && parseInt(resolved.id, 10) <= 100) {
152
- return session.execute(`comic search ${keyword}`);
158
+ return session.execute(`comic.search ${keyword}`);
153
159
  }
154
- return session.execute(`comic download ${resolved.id}`);
160
+ return session.execute(`comic.download ${resolved.id}`);
155
161
  }
156
162
  // Fallback to search
157
- return session.execute(`comic search ${keyword}`);
163
+ return session.execute(`comic.search ${keyword}`);
158
164
  });
159
- async function fetchAndShowDetail(session, source, id) {
165
+ // 构建单个漫画的详情文本;失败返回 null
166
+ async function buildDetailText(source, id) {
160
167
  try {
161
- await session.send('正在获取详情...');
162
168
  const detail = await apiGet(`/api/comic/${source}/${id}`);
163
169
  if (!detail?.title)
164
- return '未找到该漫画';
170
+ return null;
165
171
  const srcLabel = source === 'jm' ? '禁漫天堂' : '哔咔漫画';
166
172
  const parts = [
167
173
  `📖 ${detail.title}`,
@@ -172,7 +178,7 @@ function apply(ctx, config) {
172
178
  ];
173
179
  if (detail.chapters?.length > 0) {
174
180
  const first = detail.chapters[0];
175
- parts.push(`\n第一话: [${first.id}] ${first.name}`);
181
+ parts.push(`第一话: [${first.id}] ${first.name}`);
176
182
  if (detail.chapters.length > 1) {
177
183
  parts.push(`共 ${detail.chapters.length} 话,如需下载请使用 comic download <ID> [章节ID]`);
178
184
  }
@@ -180,12 +186,17 @@ function apply(ctx, config) {
180
186
  return parts.join('\n');
181
187
  }
182
188
  catch (err) {
183
- logger.error('获取详情失败:', err.message);
184
- return `获取详情失败: ${err.message}`;
189
+ logger.error(`获取详情失败 [${source}/${id}]:`, err.message);
190
+ return null;
185
191
  }
186
192
  }
193
+ async function fetchAndShowDetail(session, source, id) {
194
+ await session.send('正在获取详情...');
195
+ const text = await buildDetailText(source, id);
196
+ return text || '未找到该漫画';
197
+ }
187
198
  // comic search <keyword>
188
- ctx.command('comic search <keyword:text>', '聚合搜索漫画')
199
+ ctx.command('comic.search <keyword:text>', '聚合搜索漫画')
189
200
  .action(async ({ session }, keyword) => {
190
201
  if (!keyword)
191
202
  return '请输入搜索关键词';
@@ -223,111 +234,76 @@ function apply(ctx, config) {
223
234
  else {
224
235
  result = await apiGet('/api/search', { keyword });
225
236
  }
226
- const parts = [];
237
+ // 各平台最多取 8
238
+ const jmItems = (result.all_results?.jm || []).slice(0, 8).map(c => ({ ...c, source: 'jm' }));
239
+ const bikaItems = (result.all_results?.bika || []).slice(0, 8).map(c => ({ ...c, source: 'bika' }));
240
+ const totalResults = jmItems.length + bikaItems.length;
241
+ if (totalResults === 0) {
242
+ return `没有找到关于「${keyword}」的漫画。`;
243
+ }
244
+ // 统一编号:禁漫在前,哔咔在后,供回复序号下载
245
+ const all = [];
246
+ const jmLines = [];
247
+ const bikaLines = [];
248
+ let idx = 1;
249
+ for (const item of jmItems) {
250
+ all.push(item);
251
+ jmLines.push(`${idx}. [禁漫天堂] ${item.title} 作者:${item.author || '佚名'} (ID: ${item.id})`);
252
+ idx++;
253
+ }
254
+ for (const item of bikaItems) {
255
+ all.push(item);
256
+ bikaLines.push(`${idx}. [哔咔漫画] ${item.title} 作者:${item.author || '佚名'} (ID: ${item.id})`);
257
+ idx++;
258
+ }
259
+ if (!session) {
260
+ // 无会话环境(理论上不会发生),降级为纯文本
261
+ const lines = [];
262
+ if (jmLines.length)
263
+ lines.push('【 禁漫天堂 (JMComic) 】', ...jmLines);
264
+ if (bikaLines.length)
265
+ lines.push('【 哔咔漫画 (Bika) 】', ...bikaLines);
266
+ return lines.join('\n');
267
+ }
268
+ // 合并转发:禁漫一条、哔咔一条
269
+ const msgElements = [];
270
+ let header = `🔍 关键词「${keyword}」共找到 ${totalResults} 个结果`;
227
271
  if (result.best_match?.title) {
228
272
  const bm = result.best_match;
229
- const srcLabel = bm.source === 'jm' ? '禁漫天堂' : '哔咔漫画';
230
- parts.push(`🏆 最佳匹配 [${srcLabel}]:`);
231
- parts.push(` ${bm.title} 作者:${bm.author || '佚名'}`);
232
- parts.push('');
273
+ const bmLabel = bm.source === 'jm' ? '禁漫天堂' : '哔咔漫画';
274
+ header += `\n🏆 最佳匹配 [${bmLabel}]: ${bm.title}`;
233
275
  }
234
- const jmList = result.all_results?.jm || [];
235
- const bikaList = result.all_results?.bika || [];
236
- const all = [];
237
- const jmItems = jmList.map(c => ({ ...c, source: 'jm' }));
238
- const bikaItems = bikaList.map(c => ({ ...c, source: 'bika' }));
239
- const totalResults = jmItems.length + bikaItems.length;
240
- if (totalResults > 0) {
241
- if (totalResults > 5) {
242
- parts.push(`共找到 ${totalResults} 个结果,已生成合并转发记录:`);
243
- if (session) {
244
- await session.send(parts.join('\n'));
245
- const msgElements = [];
246
- let currentIndex = 1;
247
- if (jmItems.length > 0) {
248
- msgElements.push((0, koishi_1.h)('message', '【 禁漫天堂 (JMComic) 】'));
249
- for (const item of jmItems) {
250
- all.push(item);
251
- msgElements.push((0, koishi_1.h)('message', ` ${currentIndex}. [禁漫天堂] ${item.title} 作者:${item.author || '佚名'}`));
252
- currentIndex++;
253
- }
254
- }
255
- if (bikaItems.length > 0) {
256
- msgElements.push((0, koishi_1.h)('message', '【 哔咔漫画 (Bika) 】'));
257
- for (const item of bikaItems) {
258
- all.push(item);
259
- msgElements.push((0, koishi_1.h)('message', ` ${currentIndex}. [哔咔漫画] ${item.title} 作者:${item.author || '佚名'}`));
260
- currentIndex++;
261
- }
262
- }
263
- msgElements.push((0, koishi_1.h)('message', '💡 请查看上述列表后,在当前会话直接回复序号进行下载,回复其他内容退出。'));
264
- await session.send((0, koishi_1.h)('message', { forward: true }, msgElements));
265
- }
266
- else {
267
- parts.push(formatComics(jmItems, '禁漫天堂'));
268
- parts.push('');
269
- parts.push(formatComics(bikaItems, '哔咔漫画'));
270
- return parts.join('\n');
271
- }
276
+ msgElements.push((0, koishi_1.h)('message', header));
277
+ if (jmLines.length) {
278
+ msgElements.push((0, koishi_1.h)('message', `【 禁漫天堂 (JMComic) 】\n${jmLines.join('\n')}`));
279
+ }
280
+ if (bikaLines.length) {
281
+ msgElements.push((0, koishi_1.h)('message', `【 哔咔漫画 (Bika) 】\n${bikaLines.join('\n')}`));
282
+ }
283
+ msgElements.push((0, koishi_1.h)('message', '💡 回复序号下载(如 1),或回复「源|ID」(如 禁漫天堂|12345),回复其他内容退出。'));
284
+ await session.send((0, koishi_1.h)('message', { forward: true }, msgElements));
285
+ const answer = await session.prompt(30000);
286
+ if (!answer)
287
+ return;
288
+ const cleanAnswer = answer.trim();
289
+ let targetId = '';
290
+ const index = parseInt(cleanAnswer, 10);
291
+ if (!isNaN(index) && index > 0 && index <= all.length && /^\d+$/.test(cleanAnswer)) {
292
+ targetId = all[index - 1].id;
293
+ }
294
+ else {
295
+ const match = cleanAnswer.match(/^(?:禁漫天堂|哔咔漫画|禁漫|哔咔|jm|bika)[||](.+)$/i);
296
+ if (match) {
297
+ targetId = match[1].trim();
272
298
  }
273
299
  else {
274
- parts.push(`共找到 ${totalResults} 个结果:\n`);
275
- let currentIndex = 1;
276
- if (jmItems.length > 0) {
277
- parts.push('【 禁漫天堂 (JMComic) 】');
278
- for (const item of jmItems) {
279
- all.push(item);
280
- parts.push(` ${currentIndex}. [禁漫天堂] ${item.title} 作者:${item.author || '佚名'}`);
281
- currentIndex++;
282
- }
283
- parts.push('');
284
- }
285
- if (bikaItems.length > 0) {
286
- parts.push('【 哔咔漫画 (Bika) 】');
287
- for (const item of bikaItems) {
288
- all.push(item);
289
- parts.push(` ${currentIndex}. [哔咔漫画] ${item.title} 作者:${item.author || '佚名'}`);
290
- currentIndex++;
291
- }
292
- parts.push('');
293
- }
294
- parts.push('输入序号(例如:1)或「源|ID」(例如:禁漫天堂|12345)进行下载');
295
- if (session) {
296
- await session.send(parts.join('\n'));
297
- }
298
- else {
299
- return parts.join('\n');
300
- }
301
- }
302
- if (session) {
303
- const answer = await session.prompt(30000);
304
- if (!answer)
305
- return;
306
- const cleanAnswer = answer.trim();
307
- let targetId = '';
308
- const index = parseInt(cleanAnswer, 10);
309
- if (!isNaN(index) && index > 0 && index <= all.length) {
310
- const selected = all[index - 1];
311
- targetId = selected.id;
312
- }
313
- else {
314
- const match = cleanAnswer.match(/^(?:禁漫天堂|哔咔漫画|禁漫|哔咔|jm|bika)[||](.+)$/i);
315
- if (match) {
316
- targetId = match[1].trim();
317
- }
318
- else {
319
- targetId = cleanAnswer;
320
- }
321
- }
322
- if (targetId) {
323
- return session.execute(`comic download ${targetId}`);
324
- }
325
- return;
300
+ return; // 非有效输入,退出
326
301
  }
327
302
  }
328
- else {
329
- return '没有找到任何结果';
303
+ if (targetId) {
304
+ return session.execute(`comic.download ${targetId}`);
330
305
  }
306
+ return;
331
307
  }
332
308
  catch (err) {
333
309
  logger.error('搜索失败:', err.message);
@@ -335,7 +311,7 @@ function apply(ctx, config) {
335
311
  }
336
312
  });
337
313
  // comic download <id> [chapterId]
338
- ctx.command('comic download <id:string> [chapterId:string]', '下载漫画PDF')
314
+ ctx.command('comic.download <id:string> [chapterId:string]', '下载漫画PDF')
339
315
  .action(async ({ session }, id, chapterId) => {
340
316
  if (!id)
341
317
  return '用法: comic download <ID> [章节ID]';
@@ -372,9 +348,17 @@ function apply(ctx, config) {
372
348
  }
373
349
  try {
374
350
  await session.send('正在获取漫画详情...');
375
- const detail = await apiGet(`/api/comic/${source}/${targetId}`);
376
- if (!detail?.title)
377
- return '未找到该漫画';
351
+ let detail;
352
+ try {
353
+ detail = await apiGet(`/api/comic/${source}/${targetId}`);
354
+ }
355
+ catch (e) {
356
+ return `获取详情失败(后端请求出错):${e.message}\n可能原因:comic-api 未启动、源站反爬拦截或 ID 不存在。`;
357
+ }
358
+ if (!detail?.title) {
359
+ const srcLabel = source === 'jm' ? '禁漫天堂' : '哔咔漫画';
360
+ return `未找到该漫画 [${srcLabel} ID: ${targetId}]\n请确认 ID 是否正确;若是哔咔需先登录,禁漫可能被源站临时拦截。`;
361
+ }
378
362
  if (!detail.chapters?.length)
379
363
  return '该漫画没有可下载的章节';
380
364
  const chapter = chapterId
@@ -433,141 +417,125 @@ function apply(ctx, config) {
433
417
  }
434
418
  });
435
419
  // comic detail <id>
436
- ctx.command('comic detail <id:text>', '查看漫画详情')
420
+ ctx.command('comic.detail <id:text>', '查看漫画详情')
437
421
  .action(async ({ session }, query) => {
438
422
  if (!query)
439
423
  return '用法: comic detail <关键词> 或 comic detail <ID>';
440
424
  if (!session)
441
425
  return '此命令仅支持在会话中使用';
426
+ // 直接给定 ID/源:只查该来源
442
427
  const resolved = determineSource(query);
443
428
  if (resolved) {
444
429
  return fetchAndShowDetail(session, resolved.source, resolved.id);
445
430
  }
431
+ // 关键词模式:禁漫和哔咔各取 best 匹配,分别展示详情
446
432
  try {
433
+ await session.send('正在搜索并获取详情...');
447
434
  const result = await apiGet('/api/search', { keyword: query });
448
- const jmList = result.all_results?.jm || [];
449
- const bikaList = result.all_results?.bika || [];
450
- const all = [
451
- ...jmList.map(c => ({ ...c, source: 'jm' })),
452
- ...bikaList.map(c => ({ ...c, source: 'bika' })),
453
- ];
454
- if (all.length === 0) {
435
+ const jmBest = (result.all_results?.jm || [])[0];
436
+ const bikaBest = (result.all_results?.bika || [])[0];
437
+ if (!jmBest && !bikaBest) {
455
438
  return `没有找到关于「${query}」的漫画。`;
456
439
  }
457
- if (all.length === 1) {
458
- const chosen = all[0];
459
- return fetchAndShowDetail(session, chosen.source, chosen.id);
460
- }
461
- const parts = [];
462
- if (result.best_match?.title) {
463
- const bm = result.best_match;
464
- const srcLabel = bm.source === 'jm' ? '禁漫天堂' : '哔咔漫画';
465
- parts.push(`🏆 最佳匹配 [${srcLabel}]: ${bm.title}`);
466
- parts.push('');
467
- }
468
- if (all.length > 5) {
469
- parts.push(`找到 ${all.length} 个结果,已生成合并转发记录:`);
470
- await session.send(parts.join('\n'));
471
- const msgElements = all.map((c, i) => {
472
- const src = c.source === 'jm' ? '禁漫天堂' : '哔咔漫画';
473
- return (0, koishi_1.h)('message', `${i + 1}. [${src}] ${c.title} 作者:${c.author || '佚名'}`);
474
- });
475
- msgElements.push((0, koishi_1.h)('message', `💡 请回复序号(1-${all.length})查看详情,或回复其他任意内容退出。`));
476
- await session.send((0, koishi_1.h)('message', { forward: true }, msgElements));
477
- }
478
- else {
479
- parts.push(`找到以下多个结果,请回复序号(1-${all.length})查看详情,或回复其他任意内容退出:`);
480
- parts.push(formatComics(all));
481
- await session.send(parts.join('\n'));
482
- }
483
- const answer = await session.prompt(30000);
484
- if (!answer)
485
- return;
486
- const trimmed = answer.trim();
487
- const index = parseInt(trimmed, 10);
488
- if (!isNaN(index) && index >= 1 && index <= all.length) {
489
- const chosen = all[index - 1];
490
- return fetchAndShowDetail(session, chosen.source, chosen.id);
491
- }
492
- else {
493
- return '输入无效,已退出。';
494
- }
440
+ const [jmText, bikaText] = await Promise.all([
441
+ jmBest ? buildDetailText('jm', jmBest.id) : Promise.resolve(null),
442
+ bikaBest ? buildDetailText('bika', bikaBest.id) : Promise.resolve(null),
443
+ ]);
444
+ const msgElements = [
445
+ (0, koishi_1.h)('message', `🔍 关键词「${query}」各平台最相似结果详情:`),
446
+ ];
447
+ msgElements.push((0, koishi_1.h)('message', jmText || '禁漫天堂 】无匹配结果'));
448
+ msgElements.push((0, koishi_1.h)('message', bikaText || '【 哔咔漫画 】无匹配结果(或需先登录)'));
449
+ await session.send((0, koishi_1.h)('message', { forward: true }, msgElements));
450
+ return;
495
451
  }
496
452
  catch (err) {
497
453
  logger.error('详情搜索失败:', err.message);
498
454
  return `查询失败: ${err.message}`;
499
455
  }
500
456
  });
501
- // comic leaderboard [mode]
502
- ctx.command('comic leaderboard [mode:string]', '查看排行榜')
503
- .action(async ({ session }, mode) => {
457
+ // comic leaderboard [mode] [page]
458
+ ctx.command('comic.leaderboard [mode:string] [page:number]', '查看排行榜')
459
+ .action(async ({ session }, mode, page) => {
460
+ if (!session)
461
+ return '此命令仅支持在会话中使用';
462
+ // 未指定类型默认日榜(最新的当日榜单)
504
463
  const targetMode = (mode || 'day').toLowerCase();
505
464
  if (!['day', 'week', 'month', 'total'].includes(targetMode)) {
506
465
  return 'mode 必须是 day/week/month/total';
507
466
  }
467
+ const targetPage = Math.max(1, Math.floor(page || 1));
468
+ const query = { mode: targetMode, page: String(targetPage) };
508
469
  try {
509
470
  const [jmResult, bikaResult] = await Promise.all([
510
- apiGet('/api/jm/leaderboard', { mode: targetMode }).catch(() => null),
511
- apiGet('/api/bika/leaderboard', { mode: targetMode }).catch(() => null),
471
+ apiGet('/api/jm/leaderboard', query).catch(() => null),
472
+ apiGet('/api/bika/leaderboard', query).catch(() => null),
512
473
  ]);
513
474
  const modeMap = { day: '日榜', week: '周榜', month: '月榜', total: '总榜' };
514
- const parts = [];
515
- const formatLeaderboard = (comics) => {
475
+ const formatList = (comics) => {
516
476
  if (!comics || comics.length === 0)
517
477
  return '暂无数据或获取失败';
518
- return comics.map((c, i) => {
519
- return `${i + 1}. ${c.title} 作者:${c.author || '佚名'} (ID: ${c.id})`;
520
- }).join('\n');
478
+ return comics.map((c, i) => `${i + 1}. ${c.title} 作者:${c.author || '佚名'} (ID: ${c.id})`).join('\n');
521
479
  };
522
- parts.push(`【 禁漫天堂 (JMComic) ${modeMap[targetMode]} 】`);
523
- if (jmResult && jmResult.success !== false && jmResult.data?.length) {
524
- parts.push(formatLeaderboard(jmResult.data));
525
- }
526
- else {
527
- parts.push('暂无数据或获取失败');
528
- }
529
- parts.push('');
530
- parts.push(`【 哔咔漫画 (Bika) ${modeMap[targetMode]} 】`);
531
- if (bikaResult && bikaResult.success !== false && bikaResult.data?.length) {
532
- parts.push(formatLeaderboard(bikaResult.data));
533
- }
534
- else {
535
- parts.push('暂无数据或获取失败');
536
- }
537
- return parts.join('\n');
480
+ const jmOk = jmResult && jmResult.success !== false && jmResult.data?.length;
481
+ const bikaOk = bikaResult && bikaResult.success !== false && bikaResult.data?.length;
482
+ const jmText = `【 禁漫天堂 (JMComic) ${modeMap[targetMode]} · 第${targetPage}页 】\n` + (jmOk ? formatList(jmResult.data) : '暂无数据或获取失败');
483
+ // 哔咔无总榜,自动回退日榜
484
+ const bikaModeLabel = targetMode === 'total' ? '日榜(哔咔无总榜)' : modeMap[targetMode];
485
+ const bikaText = `【 哔咔漫画 (Bika) ${bikaModeLabel} · 第${targetPage}页 】\n` + (bikaOk ? formatList(bikaResult.data) : '暂无数据或获取失败(哔咔需先登录)');
486
+ // 禁漫、哔咔分成 2 条独立消息发送
487
+ await session.send(jmText);
488
+ await session.send(bikaText);
489
+ return;
538
490
  }
539
491
  catch (err) {
540
492
  logger.error('获取排行榜失败:', err.message);
541
493
  return `获取排行榜失败: ${err.message}`;
542
494
  }
543
495
  });
496
+ // 翻页累积拉取,直到达到 limit 条或没有更多(最多翻 maxPages 页防止狂刷后端)
497
+ async function fetchUpTo(source, endpoint, limit, maxPages = 5) {
498
+ const acc = [];
499
+ for (let page = 1; page <= maxPages && acc.length < limit; page++) {
500
+ let res = null;
501
+ try {
502
+ res = await apiGet(`/api/${source}/${endpoint}`, { page: String(page) });
503
+ }
504
+ catch {
505
+ break;
506
+ }
507
+ if (!res || res.success === false || !res.data?.length)
508
+ break;
509
+ acc.push(...res.data);
510
+ if (res.data.length === 0)
511
+ break;
512
+ }
513
+ return acc.slice(0, limit);
514
+ }
544
515
  // comic latest
545
- ctx.command('comic latest', '查看最近更新')
516
+ ctx.command('comic.latest', '查看最近更新')
546
517
  .alias('最新漫画')
547
518
  .alias('漫画更新')
548
- .action(async ({ session }) => {
519
+ .option('number', '-n <count:number> 每个平台显示数量(默认10,最多50)')
520
+ .action(async ({ session, options }) => {
549
521
  if (!session)
550
522
  return '此命令仅支持在会话中使用';
523
+ const limit = Math.min(50, Math.max(1, Math.floor(options?.number || 10)));
551
524
  try {
552
- const [jmResult, bikaResult] = await Promise.all([
553
- apiGet('/api/jm/latest').catch(() => null),
554
- apiGet('/api/bika/latest').catch(() => null),
525
+ const [jmItems, bikaItems] = await Promise.all([
526
+ fetchUpTo('jm', 'latest', limit).catch(() => []),
527
+ fetchUpTo('bika', 'latest', limit).catch(() => []),
555
528
  ]);
556
- const formatLatest = (comics) => {
529
+ const formatList = (comics) => {
557
530
  if (!comics || comics.length === 0)
558
531
  return '暂无数据或获取失败';
559
- const list = comics.slice(0, 20);
560
- return list.map((c, i) => {
561
- return `${i + 1}. ${c.title} 作者:${c.author || '佚名'} (ID: ${c.id})`;
562
- }).join('\n');
532
+ return comics.map((c, i) => `${i + 1}. ${c.title} 作者:${c.author || '佚名'} (ID: ${c.id})`).join('\n');
563
533
  };
564
- const jmText = `【 禁漫天堂 (JMComic) 最近更新 】\n` + (jmResult && jmResult.success !== false && jmResult.data?.length ? formatLatest(jmResult.data) : '暂无数据或获取失败');
565
- const bikaText = `【 哔咔漫画 (Bika) 最近更新 】\n` + (bikaResult && bikaResult.success !== false && bikaResult.data?.length ? formatLatest(bikaResult.data) : '暂无数据或获取失败');
566
- const msgElements = [
567
- (0, koishi_1.h)('message', jmText),
568
- (0, koishi_1.h)('message', bikaText)
569
- ];
570
- await session.send((0, koishi_1.h)('message', { forward: true }, msgElements));
534
+ const jmText = `【 禁漫天堂 (JMComic) 最近更新 · ${jmItems.length}条 】\n` + formatList(jmItems);
535
+ const bikaText = `【 哔咔漫画 (Bika) 最近更新 · ${bikaItems.length}条 】\n` + (bikaItems.length ? formatList(bikaItems) : '暂无数据或获取失败(哔咔需先登录)');
536
+ // 禁漫、哔咔分成 2 条独立消息发送
537
+ await session.send(jmText);
538
+ await session.send(bikaText);
571
539
  return;
572
540
  }
573
541
  catch (err) {
@@ -575,182 +543,213 @@ function apply(ctx, config) {
575
543
  return `获取最近更新失败: ${err.message}`;
576
544
  }
577
545
  });
578
- // comic random
579
- ctx.command('comic random', '随机推荐漫画')
580
- .alias('随机漫画')
581
- .action(async ({ session }) => {
582
- const source = Math.random() < 0.5 ? 'jm' : 'bika';
583
- try {
584
- const result = await apiGet(`/api/${source}/random`);
585
- const srcLabel = source === 'jm' ? '禁漫天堂' : '哔咔漫画';
586
- if (result && result.success !== false && result.data?.length) {
587
- const comic = result.data[Math.floor(Math.random() * result.data.length)];
588
- if (comic?.title) {
589
- return `🎲 随机推荐 [${srcLabel}]\n书名: ${comic.title}\n作者: ${comic.author || '佚名'}\nID: ${comic.id}\n(如需下载,请输入 comic.download ${comic.id})`;
590
- }
546
+ // 随机推荐:多次调用累积去重,直到达到 limit 条或尝试上限
547
+ async function fetchRandomUpTo(source, limit, maxTries = 4) {
548
+ const map = new Map();
549
+ for (let i = 0; i < maxTries && map.size < limit; i++) {
550
+ let res = null;
551
+ try {
552
+ res = await apiGet(`/api/${source}/random`);
553
+ }
554
+ catch {
555
+ break;
556
+ }
557
+ if (!res || res.success === false || !res.data?.length)
558
+ break;
559
+ for (const item of res.data) {
560
+ if (item?.id && !map.has(item.id))
561
+ map.set(item.id, item);
591
562
  }
592
- return `${srcLabel} 随机推荐暂无数据`;
593
563
  }
594
- catch (err) {
595
- const srcLabel = source === 'jm' ? '禁漫天堂' : '哔咔漫画';
596
- logger.error(`获取${srcLabel}随机推荐失败:`, err.message);
597
- return `获取随机推荐失败: ${err.message}`;
564
+ return Array.from(map.values()).slice(0, limit);
565
+ }
566
+ // comic random
567
+ ctx.command('comic.random', '随机推荐漫画')
568
+ .alias('随机漫画')
569
+ .option('number', '-n <count:number> 每个平台推荐数量(默认5,最多20)')
570
+ .action(async ({ session, options }) => {
571
+ const limit = Math.min(20, Math.max(1, Math.floor(options?.number || 5)));
572
+ const [jmItems, bikaItems] = await Promise.all([
573
+ fetchRandomUpTo('jm', limit).catch(() => []),
574
+ fetchRandomUpTo('bika', limit).catch(() => []),
575
+ ]);
576
+ const formatList = (comics, label) => {
577
+ if (!comics || comics.length === 0)
578
+ return `【 ${label} 】暂无数据或获取失败`;
579
+ const lines = comics.map((c, i) => `${i + 1}. [${label}] ${c.title} 作者:${c.author || '佚名'} (ID: ${c.id})`);
580
+ return `🎲 ${label} 随机推荐 · ${comics.length}个\n${lines.join('\n')}`;
581
+ };
582
+ const jmText = formatList(jmItems, '禁漫天堂');
583
+ const bikaText = bikaItems.length ? formatList(bikaItems, '哔咔漫画') : '🎲 哔咔漫画 随机推荐\n暂无数据或获取失败(哔咔需先登录)';
584
+ if (session) {
585
+ // 禁漫、哔咔分成 2 条独立消息发送
586
+ await session.send(jmText);
587
+ await session.send(bikaText);
588
+ await session.send('💡 如需下载,请使用 comic download <ID>');
589
+ return;
598
590
  }
591
+ return [jmText, '', bikaText].join('\n');
599
592
  });
600
593
  // ========== ChatLuna 工具注册 ==========
601
594
  ctx.on('ready', async () => {
602
- if (!config.tool.enabled)
603
- return;
604
595
  if (!ctx.chatluna) {
605
596
  logger.info('chatluna 未安装,跳过注册 ChatLuna 工具');
606
597
  return;
607
598
  }
608
- const toolName = (config.tool.name || 'comic').trim() || 'comic';
609
- ctx.chatluna.platform.registerTool(toolName, {
610
- description: config.tool.description || '漫画搜索与下载工具',
611
- selector() {
612
- return true;
613
- },
614
- createTool() {
615
- return createComicTool(ctx, config);
616
- },
617
- meta: {
618
- source: 'extension',
619
- group: 'comic',
620
- tags: ['comic', 'manga', 'jmcomic', 'bika'],
621
- defaultAvailability: {
622
- enabled: true,
623
- main: true,
624
- chatluna: true,
625
- characterScope: 'all',
599
+ const chatluna = ctx.chatluna;
600
+ const registerSingleTool = (name, description, schema, run) => {
601
+ chatluna.platform.registerTool(name, {
602
+ description,
603
+ selector() {
604
+ return true;
626
605
  },
627
- },
628
- });
629
- logger.info(`ChatLuna 工具「${toolName}」已注册`);
630
- });
631
- }
632
- const comicToolSchema = zod_1.z.object({
633
- action: zod_1.z.enum(['search', 'detail', 'leaderboard', 'latest', 'random', 'download'])
634
- .describe('操作类型:search=搜索, detail=详情, leaderboard=排行榜, latest=最近更新, random=随机推荐, download=下载漫画/本子并发送给用户'),
635
- keyword: zod_1.z.string().optional()
636
- .describe('搜索关键词(action=search 时必填)'),
637
- source: zod_1.z.enum(['jm', 'bika']).optional()
638
- .describe('漫画源:jm=禁漫天堂, bika=哔咔漫画。不填则根据需要自动检测或使用默认源'),
639
- comic_id: zod_1.z.string().optional()
640
- .describe('漫画ID(action=detail 或 action=download 时必填)'),
641
- chapter_id: zod_1.z.string().optional()
642
- .describe('章节ID(action=download 时选填,不填默认下载第一话)'),
643
- mode: zod_1.z.enum(['day', 'week', 'month', 'total']).optional()
644
- .describe('排行榜时间维度(action=leaderboard 时使用):day=日榜, week=周榜, month=月榜, total=总榜'),
645
- page: zod_1.z.number().int().min(1).optional()
646
- .describe('页码,默认1'),
647
- });
648
- function createComicTool(ctx, cfg) {
649
- const description = (cfg.tool.description || '').trim() || '漫画搜索与下载工具。支持禁漫天堂(JM)和哔咔漫画(Bika)双平台。包含动作:search (搜索), detail (查看详情), leaderboard (排行榜), latest (最近更新), random (随机推荐), download (下载本子并发送)。提示:如果用户给出的本子名、角色名或关键词模糊、包含缩写或拼写不够精确,或者使用该工具搜索未返回结果,你必须先调用联网搜索工具检索获取该本子的准确正式名称、画师/作者或完整标题,然后再用精准的关键词调用此工具。';
650
- const name = (cfg.tool.name || 'comic').trim() || 'comic';
651
- return (0, tools_1.tool)(async (input, runConfig) => {
652
- const logger = ctx.logger('comic');
653
- const source = input.source || cfg.defaultSource || 'jm';
654
- const apiBase = cfg.apiBase.replace(/\/+$/, '');
655
- const apiGet = async (path, params) => {
656
- let url = apiBase + path;
657
- if (params) {
658
- const qs = new URLSearchParams(params).toString();
659
- if (qs)
660
- url += '?' + qs;
661
- }
662
- return ctx.http.get(url);
606
+ createTool() {
607
+ return (0, tools_1.tool)(run, { name, description, schema });
608
+ },
609
+ meta: {
610
+ source: 'extension',
611
+ group: 'comic',
612
+ tags: ['comic', 'manga', 'jmcomic', 'bika'],
613
+ defaultAvailability: {
614
+ enabled: true,
615
+ main: true,
616
+ chatluna: true,
617
+ characterScope: 'all',
618
+ },
619
+ },
620
+ });
621
+ logger.info(`ChatLuna 工具「${name}」已注册`);
663
622
  };
664
- try {
665
- switch (input.action) {
666
- case 'download': {
667
- if (!input.comic_id) {
668
- return JSON.stringify({ error: '下载漫画时 comic_id 不能为空' });
669
- }
670
- const session = runConfig?.configurable?.session;
671
- if (!session) {
672
- return JSON.stringify({ error: '无法获取当前会话,不支持下载操作。请让用户在聊天界面直接使用 comic 命令或 comic.download 命令手动下载。' });
673
- }
674
- const cmd = input.chapter_id ? `comic.download ${input.comic_id} ${input.chapter_id}` : `comic.download ${input.comic_id}`;
675
- session.execute(cmd);
676
- return JSON.stringify({ success: true, message: `已在后台启动漫画下载,命令为: ${cmd}。请告知用户正在下载,并让其留意后续接收的 PDF 文件与密码。` });
677
- }
678
- case 'search': {
679
- if (!input.keyword) {
680
- return JSON.stringify({ error: '搜索时 keyword 不能为空' });
681
- }
682
- const result = await apiGet('/api/search', { keyword: input.keyword });
683
- const jmList = result.all_results?.jm || [];
684
- const bikaList = result.all_results?.bika || [];
685
- const all = [
686
- ...jmList.map(c => ({ ...c, source: 'jm' })),
687
- ...bikaList.map(c => ({ ...c, source: 'bika' })),
688
- ];
689
- return JSON.stringify({
690
- keyword: input.keyword,
691
- best_match: result.best_match,
692
- total: all.length,
693
- results: all.slice(0, 20),
694
- });
695
- }
696
- case 'detail': {
697
- if (!input.comic_id) {
698
- return JSON.stringify({ error: '查看详情时 comic_id 不能为空' });
699
- }
700
- const detail = await apiGet(`/api/comic/${source}/${input.comic_id}`);
701
- if (!detail?.title) {
702
- return JSON.stringify({ error: '未找到该漫画' });
703
- }
704
- return JSON.stringify({
705
- title: detail.title,
706
- author: detail.author,
707
- description: detail.description,
708
- source: detail.source || source,
709
- chapter_count: detail.chapters?.length || 0,
710
- chapters: detail.chapters?.slice(0, 30) || [],
711
- });
712
- }
713
- case 'leaderboard': {
714
- const mode = input.mode || 'day';
715
- const page = String(input.page || 1);
716
- const result = await apiGet(`/api/${source}/leaderboard`, { mode, page });
717
- return JSON.stringify({
718
- source,
719
- mode,
720
- page: Number(page),
721
- total: result.data?.length || 0,
722
- results: result.data || [],
723
- });
724
- }
725
- case 'latest': {
726
- const page = String(input.page || 1);
727
- const result = await apiGet(`/api/${source}/latest`, { page });
728
- return JSON.stringify({
729
- source,
730
- page: Number(page),
731
- total: result.data?.length || 0,
732
- results: result.data || [],
733
- });
623
+ // 1. comic_search
624
+ registerSingleTool('comic_search', '搜索漫画,支持禁漫天堂(JM)和哔咔漫画(Bika)双平台聚合搜索。提示:如果用户给出的本子名、角色名或关键词模糊,或搜索未返回结果,必须先调用联网搜索工具检索获取该本子的准确正式名称或完整标题,然后再用精准的关键词调用此工具。', zod_1.z.object({
625
+ keyword: zod_1.z.string().describe('搜索关键词'),
626
+ }), async (input) => {
627
+ try {
628
+ const result = await apiGet('/api/search', { keyword: input.keyword });
629
+ const jmList = result.all_results?.jm || [];
630
+ const bikaList = result.all_results?.bika || [];
631
+ const all = [
632
+ ...jmList.map(c => ({ ...c, source: 'jm' })),
633
+ ...bikaList.map(c => ({ ...c, source: 'bika' })),
634
+ ];
635
+ return JSON.stringify({
636
+ keyword: input.keyword,
637
+ best_match: result.best_match,
638
+ total: all.length,
639
+ results: all.slice(0, 20),
640
+ });
641
+ }
642
+ catch (err) {
643
+ logger.error('工具 comic_search 调用失败:', err.message);
644
+ return JSON.stringify({ error: err.message || '请求失败' });
645
+ }
646
+ });
647
+ // 2. comic_detail
648
+ registerSingleTool('comic_detail', '查看指定来源和ID的漫画详情,包括标题、作者、简介以及可下载的章节列表。', zod_1.z.object({
649
+ comic_id: zod_1.z.string().describe('漫画ID'),
650
+ source: zod_1.z.enum(['jm', 'bika']).optional().default('jm').describe('漫画源:jm=禁漫天堂, bika=哔咔漫画。默认jm。'),
651
+ }), async (input) => {
652
+ try {
653
+ const source = input.source || 'jm';
654
+ const detail = await apiGet(`/api/comic/${source}/${input.comic_id}`);
655
+ if (!detail?.title) {
656
+ return JSON.stringify({ error: '未找到该漫画' });
734
657
  }
735
- case 'random': {
736
- const result = await apiGet(`/api/${source}/random`);
737
- return JSON.stringify({
738
- source,
739
- result: result.data || null,
740
- });
658
+ return JSON.stringify({
659
+ title: detail.title,
660
+ author: detail.author,
661
+ description: detail.description,
662
+ source: detail.source || source,
663
+ chapter_count: detail.chapters?.length || 0,
664
+ chapters: detail.chapters?.slice(0, 30) || [],
665
+ });
666
+ }
667
+ catch (err) {
668
+ logger.error('工具 comic_detail 调用失败:', err.message);
669
+ return JSON.stringify({ error: err.message || '请求失败' });
670
+ }
671
+ });
672
+ // 3. comic_leaderboard
673
+ registerSingleTool('comic_leaderboard', '查看指定漫画源的排行榜。', zod_1.z.object({
674
+ source: zod_1.z.enum(['jm', 'bika']).optional().default('jm').describe('漫画源:jm=禁漫天堂, bika=哔咔漫画。默认jm。'),
675
+ mode: zod_1.z.enum(['day', 'week', 'month', 'total']).optional().default('day').describe('排行榜时间维度:day=日榜, week=周榜, month=月榜, total=总榜。默认day。'),
676
+ page: zod_1.z.number().int().min(1).optional().default(1).describe('页码,默认1。'),
677
+ }), async (input) => {
678
+ try {
679
+ const source = input.source || 'jm';
680
+ const mode = input.mode || 'day';
681
+ const page = String(input.page || 1);
682
+ const result = await apiGet(`/api/${source}/leaderboard`, { mode, page });
683
+ return JSON.stringify({
684
+ source,
685
+ mode,
686
+ page: Number(page),
687
+ total: result.data?.length || 0,
688
+ results: result.data || [],
689
+ });
690
+ }
691
+ catch (err) {
692
+ logger.error('工具 comic_leaderboard 调用失败:', err.message);
693
+ return JSON.stringify({ error: err.message || '请求失败' });
694
+ }
695
+ });
696
+ // 4. comic_latest
697
+ registerSingleTool('comic_latest', '查看指定漫画源的最近更新列表。', zod_1.z.object({
698
+ source: zod_1.z.enum(['jm', 'bika']).optional().default('jm').describe('漫画源:jm=禁漫天堂, bika=哔咔漫画。默认jm。'),
699
+ page: zod_1.z.number().int().min(1).optional().default(1).describe('页码,默认1。'),
700
+ }), async (input) => {
701
+ try {
702
+ const source = input.source || 'jm';
703
+ const page = String(input.page || 1);
704
+ const result = await apiGet(`/api/${source}/latest`, { page });
705
+ return JSON.stringify({
706
+ source,
707
+ page: Number(page),
708
+ total: result.data?.length || 0,
709
+ results: result.data || [],
710
+ });
711
+ }
712
+ catch (err) {
713
+ logger.error('工具 comic_latest 调用失败:', err.message);
714
+ return JSON.stringify({ error: err.message || '请求失败' });
715
+ }
716
+ });
717
+ // 5. comic_random
718
+ registerSingleTool('comic_random', '随机推荐指定漫画源的漫画。', zod_1.z.object({
719
+ source: zod_1.z.enum(['jm', 'bika']).optional().default('jm').describe('漫画源:jm=禁漫天堂, bika=哔咔漫画。默认jm。'),
720
+ }), async (input) => {
721
+ try {
722
+ const source = input.source || 'jm';
723
+ const result = await apiGet(`/api/${source}/random`);
724
+ return JSON.stringify({
725
+ source,
726
+ result: result.data || null,
727
+ });
728
+ }
729
+ catch (err) {
730
+ logger.error('工具 comic_random 调用失败:', err.message);
731
+ return JSON.stringify({ error: err.message || '请求失败' });
732
+ }
733
+ });
734
+ // 6. comic_download
735
+ registerSingleTool('comic_download', '下载指定ID的漫画并发送给用户。会自动启动后台下载并向用户发送PDF文件及解密密码。', zod_1.z.object({
736
+ comic_id: zod_1.z.string().describe('漫画ID'),
737
+ chapter_id: zod_1.z.string().optional().describe('章节ID(选填,不填默认下载第一话)'),
738
+ }), async (input, runConfig) => {
739
+ try {
740
+ const session = runConfig?.configurable?.session;
741
+ if (!session) {
742
+ return JSON.stringify({ error: '无法获取当前会话,不支持下载操作。请让用户在聊天界面直接使用 comic 命令或 comic.download 命令手动下载。' });
741
743
  }
742
- default:
743
- return JSON.stringify({ error: '未知操作类型' });
744
+ const cmd = input.chapter_id ? `comic.download ${input.comic_id} ${input.chapter_id}` : `comic.download ${input.comic_id}`;
745
+ session.execute(cmd);
746
+ return JSON.stringify({ success: true, message: `已在后台启动漫画下载,命令为: ${cmd}。请告知用户正在下载,并让其留意后续接收的 PDF 文件与密码。` });
744
747
  }
745
- }
746
- catch (err) {
747
- logger.error('工具调用失败:', err.message);
748
- return JSON.stringify({ error: err.message || '请求失败' });
749
- }
750
- }, {
751
- name,
752
- description,
753
- schema: comicToolSchema,
748
+ catch (err) {
749
+ logger.error('工具 comic_download 调用失败:', err.message);
750
+ return JSON.stringify({ error: err.message || '请求失败' });
751
+ }
752
+ });
754
753
  });
755
754
  }
756
755
  //# sourceMappingURL=index.js.map