koishi-plugin-chat-analyse 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import { Context } from 'koishi';
2
+ /** 定义了渲染列表中单行数据的格式,是一个由字符串、数字或 `Date` 对象构成的数组。 */
3
+ export type RenderListItem = (string | number | Date)[];
4
+ /**
5
+ * @interface ListRenderData
6
+ * @description 定义了调用 `renderList` 方法所需的数据结构,包含了渲染一张完整列表图片所必需的所有信息。
7
+ */
8
+ export interface ListRenderData {
9
+ title: string;
10
+ time: Date;
11
+ total?: string | number;
12
+ list: RenderListItem[];
13
+ }
14
+ /**
15
+ * @class Renderer
16
+ * @description 负责将结构化的列表数据渲染为设计精美的 PNG 图片。其核心特性是能够动态计算内容尺寸,生成布局紧凑、自适应的图片。
17
+ */
18
+ export declare class Renderer {
19
+ private ctx;
20
+ /**
21
+ * @param ctx - Koishi 的插件上下文,用于访问 `puppeteer` 等核心服务。
22
+ */
23
+ constructor(ctx: Context);
24
+ /**
25
+ * @private
26
+ * @method htmlToImage
27
+ * @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
28
+ * @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
29
+ * @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null。
30
+ */
31
+ private htmlToImage;
32
+ /**
33
+ * @private
34
+ * @method formatDate
35
+ * @description 将 `Date` 对象格式化为易于理解的相对时间或绝对日期字符串。
36
+ * @param date - 需要格式化的日期对象。
37
+ * @returns 格式化后的时间字符串。
38
+ */
39
+ private formatDate;
40
+ /**
41
+ * @public
42
+ * @method renderList
43
+ * @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
44
+ * @param data - 包含渲染所需全部信息的对象。
45
+ * @param headers - (可选) 表格的表头字符串数组。
46
+ * @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
47
+ */
48
+ renderList(data: ListRenderData, headers?: string[]): Promise<string | Buffer[]>;
49
+ }
package/lib/index.js CHANGED
@@ -222,15 +222,15 @@ var Renderer = class {
222
222
  /**
223
223
  * @private
224
224
  * @method htmlToImage
225
- * @description HTML 字符串转换为 PNG 图片 Buffer。
226
- * @param html - 要渲染的 HTML 主体内容。
227
- * @returns 返回一个包含 PNG 图片数据的 Buffer。
225
+ * @description 将一个完整的 HTML 文档字符串转换为 PNG 图片 Buffer。
226
+ * @param fullHtmlContent - 要渲染的、包含 `<html>...</html>` 的完整HTML字符串。
227
+ * @returns 返回一个包含 PNG 图片数据的 Buffer,失败则返回 null
228
228
  */
229
- async htmlToImage(html) {
229
+ async htmlToImage(fullHtmlContent) {
230
230
  const page = await this.ctx.puppeteer.page();
231
231
  try {
232
232
  await page.setViewport({ width: 720, height: 1080, deviceScaleFactor: 2 });
233
- await page.setContent(`<!DOCTYPE html><html><head><meta charset="UTF-8"><style>:root{--card-bg:#fff;--text-color:#111827;--header-color:#111827;--sub-text-color:#6b7280;--border-color:#e5e7eb;--accent-color:#4a6ee0;--chip-bg:#f3f4f6;--stripe-bg:#f9fafb;--gold:#f59e0b;--silver:#9ca3af;--bronze:#a16207}body{display:inline-block;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:0 0;margin:0;padding:8px;-webkit-font-smoothing:antialiased}.container{display:inline-block;background:var(--card-bg);border-radius:12px;padding:0;overflow:hidden;box-shadow:0 2px 4px rgba(0,0,0,.05)}.header{padding:10px 14px}.header-table{border-collapse:collapse;width:100%}.header-table-left,.header-table-right{width:1%;white-space:nowrap}.header-table-left{text-align:left}.header-table-center{text-align:center}.header-table-right{text-align:right}.title-text{font-size:18px;font-weight:600;color:var(--header-color);margin:0}.stat-chip,.time-label{display:inline-flex;align-items:baseline;padding:4px 8px;border-radius:8px;background:var(--chip-bg);font-size:13px;color:var(--sub-text-color)}.stat-chip span{font-weight:600;color:var(--text-color);margin-left:4px}.table-container{border-top:1px solid var(--border-color)}.main-table{border-collapse:collapse;width:100%}.main-table th,.main-table td{padding:8px 14px;vertical-align:middle}.main-table th{font-size:12px;font-weight:500;color:var(--sub-text-color);text-transform:uppercase;letter-spacing:.05em;background:var(--stripe-bg)}.main-table td{font-size:14px;color:var(--text-color)}.main-table tbody tr:nth-child(even){background-color:var(--stripe-bg)}.main-table .name-cell,.main-table .name-header{text-align:left}.main-table .rank-cell,.main-table .count-cell,.main-table .date-cell,.main-table .percent-cell,.main-table .header-right-align{text-align:right;white-space:nowrap;width:1%;font-variant-numeric:tabular-nums}.name-cell{font-weight:500}.rank-cell{font-weight:500;color:var(--sub-text-color)}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{color:var(--sub-text-color)}.rank-gold,.rank-silver,.rank-bronze{font-weight:600}.rank-gold{color:var(--gold)!important}.rank-silver{color:var(--silver)!important}.rank-bronze{color:var(--bronze)!important}.percent-cell{position:relative}.percent-bar{position:absolute;top:0;right:0;height:100%;background-color:var(--accent-color);opacity:.15}.percent-text{position:relative;z-index:1}</style></head><body>${html}</body></html>`, { waitUntil: "networkidle0" });
233
+ await page.setContent(fullHtmlContent, { waitUntil: "networkidle0" });
234
234
  const dimensions = await page.evaluate(() => ({ width: document.body.scrollWidth, height: document.body.scrollHeight }));
235
235
  await page.setViewport({ ...dimensions, deviceScaleFactor: 2 });
236
236
  return await page.screenshot({ type: "png", fullPage: true, omitBackground: true });
@@ -264,17 +264,19 @@ var Renderer = class {
264
264
  /**
265
265
  * @public
266
266
  * @method renderList
267
- * @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。
267
+ * @description 构建并渲染一个包含标题、统计信息和数据表格的 HTML 卡片为图片。如果数据过多,则会分片渲染成多张图片。
268
268
  * @param data - 包含渲染所需全部信息的对象。
269
269
  * @param headers - (可选) 表格的表头字符串数组。
270
- * @returns 成功时返回包含 PNG 图片的 Buffer,若列表为空则返回提示字符串。
270
+ * @returns 成功时返回包含 PNG 图片的 Buffer 数组,若列表为空则返回提示字符串。
271
271
  */
272
272
  async renderList(data, headers) {
273
273
  const { title, time, list } = data;
274
274
  if (!list?.length) return "暂无数据可供渲染";
275
+ const CHUNK_SIZE = 100;
276
+ const imageBuffers = [];
277
+ const totalItems = list.length;
275
278
  const countHeaderIndex = headers?.findIndex((h4) => ["总计发言", "条数", "次数", "数量"].includes(h4)) ?? -1;
276
- const totalValue = countHeaderIndex > -1 ? list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0) : 0;
277
- const totalCount = data.total || totalValue;
279
+ const totalCount = data.total || (countHeaderIndex > -1 ? list.reduce((sum, row) => sum + (Number(row[countHeaderIndex]) || 0), 0) : totalItems);
278
280
  const renderCell = /* @__PURE__ */ __name((cell, i) => {
279
281
  const headerText = headers?.[i] || "";
280
282
  if (headerText.includes("占比")) {
@@ -285,14 +287,34 @@ var Renderer = class {
285
287
  if (typeof cell === "number") return `<td class="count-cell">${cell.toLocaleString()}</td>`;
286
288
  return `<td class="name-cell">${String(cell)}</td>`;
287
289
  }, "renderCell");
288
- const tableHeadHtml = headers?.length ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h4) => `<th class="${typeof list[0]?.[headers.indexOf(h4)] === "string" ? "name-header" : "header-right-align"}">${h4}</th>`).join("")}</tr></thead>` : "";
289
- const tableRowsHtml = list.map((row, index) => {
290
- const rank = index + 1;
291
- const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
292
- return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
293
- }).join("");
294
- const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div></td><td class="header-table-center"><h1 class="title-text">${title}</h1></td><td class="header-table-right"><div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div></td></tr></table></div><div class="table-container"><table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></div>`;
295
- return this.htmlToImage(cardHtml);
290
+ const totalPages = Math.ceil(totalItems / CHUNK_SIZE);
291
+ for (let i = 0; i < totalItems; i += CHUNK_SIZE) {
292
+ const chunk = list.slice(i, i + CHUNK_SIZE);
293
+ const pageNum = Math.floor(i / CHUNK_SIZE) + 1;
294
+ const pageTitle = totalPages > 1 ? `${title} (第 ${pageNum}/${totalPages})` : title;
295
+ const tableRowsHtml = chunk.map((row, index) => {
296
+ const rank = i + index + 1;
297
+ const rankClass = rank === 1 ? "rank-gold" : rank === 2 ? "rank-silver" : rank === 3 ? "rank-bronze" : "";
298
+ return `<tr><td class="rank-cell ${rankClass}">${rank}</td>${row.map(renderCell).join("")}</tr>`;
299
+ }).join("");
300
+ const tableHeadHtml = headers?.length ? `<thead><tr><th class="rank-cell">#</th>${headers.map((h4) => `<th class="${typeof list[0]?.[headers.indexOf(h4)] === "string" ? "name-header" : "header-right-align"}">${h4}</th>`).join("")}</tr></thead>` : "";
301
+ const cardHtml = `<div class="container"><div class="header"><table class="header-table"><tr><td class="header-table-left"><div class="stat-chip">总计: <span>${typeof totalCount === "number" ? totalCount.toLocaleString() : totalCount}</span></div></td><td class="header-table-center"><h1 class="title-text">${pageTitle}</h1></td><td class="header-table-right"><div class="time-label">${time.toLocaleString("zh-CN", { hour12: false }).replace(/\//g, "-")}</div></td></tr></table></div><div class="table-container"><table class="main-table">${tableHeadHtml}<tbody>${tableRowsHtml}</tbody></table></div></div>`;
302
+ const fullHtml = `<!DOCTYPE html>
303
+ <html>
304
+ <head>
305
+ <meta charset="UTF-8">
306
+ <style>:root{--card-bg:#fff;--text-color:#111827;--header-color:#111827;--sub-text-color:#6b7280;--border-color:#e5e7eb;--accent-color:#4a6ee0;--chip-bg:#f3f4f6;--stripe-bg:#f9fafb;--gold:#f59e0b;--silver:#9ca3af;--bronze:#a16207}body{display:inline-block;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:0 0;margin:0;padding:8px;-webkit-font-smoothing:antialiased}.container{display:inline-block;background:var(--card-bg);border-radius:12px;padding:0;overflow:hidden;box-shadow:0 2px 4px rgba(0,0,0,.05)}.header{padding:10px 14px}.header-table{border-collapse:collapse;width:100%}.header-table-left,.header-table-right{width:1%;white-space:nowrap}.header-table-left{text-align:left}.header-table-center{text-align:center}.header-table-right{text-align:right}.title-text{font-size:18px;font-weight:600;color:var(--header-color);margin:0}.stat-chip,.time-label{display:inline-flex;align-items:baseline;padding:4px 8px;border-radius:8px;background:var(--chip-bg);font-size:13px;color:var(--sub-text-color)}.stat-chip span{font-weight:600;color:var(--text-color);margin-left:4px}.table-container{border-top:1px solid var(--border-color)}.main-table{border-collapse:collapse;width:100%}.main-table th,.main-table td{padding:8px 14px;vertical-align:middle}.main-table th{font-size:12px;font-weight:500;color:var(--sub-text-color);text-transform:uppercase;letter-spacing:.05em;background:var(--stripe-bg)}.main-table td{font-size:14px;color:var(--text-color)}.main-table tbody tr:nth-child(even){background-color:var(--stripe-bg)}.main-table .name-cell,.main-table .name-header{text-align:left}.main-table .rank-cell,.main-table .count-cell,.main-table .date-cell,.main-table .percent-cell,.main-table .header-right-align{text-align:right;white-space:nowrap;width:1%;font-variant-numeric:tabular-nums}.name-cell{font-weight:500}.rank-cell{font-weight:500;color:var(--sub-text-color)}.count-cell{font-weight:600;color:var(--accent-color)}.date-cell{color:var(--sub-text-color)}.rank-gold,.rank-silver,.rank-bronze{font-weight:600}.rank-gold{color:var(--gold)!important}.rank-silver{color:var(--silver)!important}.rank-bronze{color:var(--bronze)!important}.percent-cell{position:relative}.percent-bar{position:absolute;top:0;right:0;height:100%;background-color:var(--accent-color);opacity:.15}.percent-text{position:relative;z-index:1}</style>
307
+ </head>
308
+ <body>
309
+ ${cardHtml}
310
+ </body>
311
+ </html>`;
312
+ const imageBuffer = await this.htmlToImage(fullHtml);
313
+ if (imageBuffer) {
314
+ imageBuffers.push(imageBuffer);
315
+ }
316
+ }
317
+ return imageBuffers.length > 0 ? imageBuffers : "图片渲染失败";
296
318
  }
297
319
  };
298
320
 
@@ -329,7 +351,13 @@ var Stat = class {
329
351
  if (scope.error) return scope.error;
330
352
  try {
331
353
  const result = await handler(scope, options);
332
- return Buffer.isBuffer(result) ? import_koishi3.h.image(result, "image/png") : result;
354
+ if (typeof result === "string") return result;
355
+ if (Array.isArray(result)) {
356
+ if (result.length === 0) return "图片渲染失败";
357
+ for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
358
+ return;
359
+ }
360
+ if (Buffer.isBuffer(result)) return import_koishi3.h.image(result, "image/png");
333
361
  } catch (error) {
334
362
  this.ctx.logger.error("渲染统计图片失败:", error);
335
363
  return "渲染统计图片失败";
@@ -377,7 +405,7 @@ var Stat = class {
377
405
  const uidsInScope = guildId ? (await this.ctx.database.get("analyse_user", { channelId: guildId }, ["uid"])).map((u) => u.uid) : void 0;
378
406
  if (guildId && uidsInScope.length === 0) return "暂无指定时段内发言记录";
379
407
  if (uidsInScope) baseQuery.uid = { $in: uidsInScope };
380
- const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").limit(100).execute();
408
+ const rankStats = await this.ctx.database.select("analyse_rank").where(baseQuery).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
381
409
  if (rankStats.length === 0) return "暂无指定时段内发言记录";
382
410
  const uids = rankStats.map((s) => s.uid);
383
411
  const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName"]);
@@ -387,7 +415,12 @@ var Stat = class {
387
415
  const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
388
416
  const title = await this.generateTitle({ guildId }, { main: "发言排行", timeRange: hours, subtype: type });
389
417
  const result = await this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
390
- return Buffer.isBuffer(result) ? import_koishi3.h.image(result, "image/png") : result;
418
+ if (typeof result === "string") return result;
419
+ if (Array.isArray(result)) {
420
+ if (result.length === 0) return "图片渲染失败";
421
+ for (const buffer of result) await session.sendQueued(import_koishi3.h.image(buffer, "image/png"));
422
+ return;
423
+ }
391
424
  } catch (error) {
392
425
  this.ctx.logger.error("渲染发言排行图片失败:", error);
393
426
  return "渲染发言排行图片失败";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "聊天记录分析",
4
- "version": "0.5.1",
4
+ "version": "0.5.2",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],