软件开发创新日志 #1项目分析+二次开发

写在前面

本文章为学校课程【软件开发与创新】的作业文章,存在一定程度的简写/概括/作业模板等

观看本文你可能需要了解的:TypeScriptKoishiNapcatOneBotNode.js


项目来源与分析

由于非常想写qqbot相关的项目,也想尝试插件二次开发,所以本次分析项目来源于koishi插件市场的公开插件smmcat-faqcooking/程序员在家做饭指南,仓库地址https://github.com/smmcat/smmcat-faqcooking。在这里首先向开发者smmcat致敬,感谢

对于koishi是什么,请阅读这篇文章《如何搭建一个聊天机器人?#3 初步了解koishi、napcat以及onebot》中的koishi简介部分

现有分析

首先既然是koishi插件项目,因此本项目基于nodejs,编写语言为100%Typescript。

先看一下项目源码结构,

.
└── smmcat-faqcooking
    ├── package.json
    ├── readme.md
    ├── src
    │   ├── html.ts
    │   └── index.ts
    └── tsconfig.json

对于koishi插件,index.ts为插件入口

下面是源码

[collapse title="index.ts"]

//index.ts
import { Context, h, Schema } from 'koishi'
import { } from 'koishi-plugin-smmcat-localstorage';
import { } from 'koishi-plugin-puppeteer'
import path from 'path'
import fs from 'fs'
import { Readable } from 'stream';
import { pathToFileURL } from 'url';
import { html } from './html';
export const name = 'smmcat-faqcooking'

export interface Config {
  atQQ: boolean
  maxListLen: number
  download: boolean
  useHTML: boolean
}

export const inject = {
  required: ['localstorage'],
  optional: ['puppeteer']
};

export const Config: Schema<Config> = Schema.object({
  atQQ: Schema.boolean().default(false).description("艾特QQ [兼容机制]"),
  maxListLen: Schema.number().default(5).description("列表最大返回数量"),
  download: Schema.boolean().default(true).description('图片保存在本地'),
  useHTML: Schema.boolean().default(false).description('图形化输出')
})

export function apply(ctx: Context, config: Config) {

  /** 工具类 */
  const tool = {
    basePath: path.join(ctx.baseDir, './data'),
    async setStoreForImage(imageUrl: string, type = 'jpg'): Promise<string | null> {
      const setPath = path.join(this.basePath, './image')
      if (!fs.existsSync(setPath)) {
        fs.mkdirSync(setPath, { recursive: true });
      }
      const timestamp = new Date().getTime();
      const imagePath = path.join(setPath, `${timestamp}.${type}`);
      const response = await ctx.http.get(imageUrl, { responseType: 'stream' });
      const writer = fs.createWriteStream(imagePath);
      const responseNodeStream = Readable.fromWeb(response)
      responseNodeStream.pipe(writer);

      return await new Promise((resolve, reject) => {
        writer.on('finish', () => {
          console.log(`下载完成,文件路径 ${imagePath}`);
          resolve(pathToFileURL(imagePath).href)
        });
        writer.on('error', () => {
          reject(null)
        });
      });
    }
  }

  const baseUrl = "https://tools.mgtv100.com/external/v1/pear/cookbook";
  const cookingTool = {
    userIdList: {},
    historyList: {},
    // 初始化数据
    async intData() {
      const basePath = path.join(ctx.localstorage.basePath, "smm_cooking");
      if (!fs.existsSync(basePath)) {
        await fs.promises.mkdir(basePath, { recursive: true });
      }
      const dirList = fs.readdirSync(basePath);
      const dict = { ok: 0, err: 0 };
      const temp = {};
      const evenList = dirList.map((userId) => {
        return new Promise(async (resolve, rejects) => {
          try {
            temp[userId] = JSON.parse(await ctx.localstorage.getItem(`smm_cooking/${userId}`) || "{}");
            dict.ok++;
            resolve(true);
          } catch (error) {
            dict.err++;
            resolve(false);
          }
        });
      });
      await Promise.all(evenList);
      this.historyList = temp;
    },
    // 请求数据
    async getData(cookingName, userId) {
      try {
        const res = await ctx.http.post(baseUrl, { search_food: cookingName });
        if (res.code !== 200)
          return this.userIdList[userId] = null;
        this.userIdList[userId] = res.data;
        if (config.download) {
          const eventList = res.data.map((item, index) => {
            return new Promise(async (resolve) => {
              try {
                res.data[index].image = await tool.setStoreForImage(item.image)
                resolve(true)
              } catch (error) {
                console.log(error);
                resolve(true)
              }
            })
          })
          await Promise.all(eventList)
        }
      } catch (error) {
        this.userIdList[userId] = null;
      }
    },
    // 获取列表
    async getMenuList(cookingName, userId) {
      await this.getData(cookingName, userId);
      if (!this.userIdList[userId]) {
        return { code: false, msg: `没有找到菜系 ${cookingName} 对应数据,获取失败` };
      }
      console.log(this.userIdList[userId]);
      const msgList = this.formatMenuByText(this.userIdList[userId]);
      return { code: true, msgList };
    },
    // 文本化数据输出 菜单
    formatMenuByText(data) {
      const ListMap = data.slice(0, config.maxListLen).filter((item) => item).map((item, index) => {
        return {
          index: index + 1,
          name: item.name,
          pic: h.image(item.image),
          materials: item.materials.join("\n"),
          practice: item.practice.join("\n")
        };
      });
      console.log(ListMap);
      return ListMap;
    },
    // 文本化数据输出 详情
    async formatDetailByText(userId, index) {
      const cookItem = this.userIdList[userId]?.[index];
      if (!cookItem) return;
      this.setLocatStorehistory(userId, cookItem.name);
      return h.image(cookItem.image) + `【${cookItem.name}】

[配料目录]
${cookItem.materials.join("\n")}

[操作演示]
` + cookItem.practice.join("\n");
    },
    /** 返回图片输出 */
    async formatDetailByHTML(userId, index) {
      const cookItem = this.userIdList[userId]?.[index];
      if (!cookItem) return;
      const strHtml = html.detail(cookItem)
      return await ctx.puppeteer.render(strHtml)
    },
    // 本地存储历史 (最多40条)
    async setLocatStorehistory(userId, cookName) {
      if (!this.historyList[userId]) {
        this.historyList[userId] = [];
      }
      if (this.historyList[userId].includes(cookName))
        return;
      this.historyList[userId].push(cookName);
      if (this.historyList[userId].length > 40) {
        this.historyList[userId].shift();
      }
      await ctx.localstorage.setItem(`smm_cooking/${userId}`, JSON.stringify(this.historyList[userId]));
    }
  };
  ctx.command("做菜指南")

  ctx.command("做菜指南/做菜统计").action(async ({ session }) => {
    let at = "";
    if (config.atQQ) {
      at = `<at id="${session.userId}" />`;
    }
    await session.send(at + "稍等,正在获取数据...");
    const cookInfo = {};
    Object.values(cookingTool.historyList).forEach((item: any) => {
      item.forEach((i) => {
        if (!cookInfo[i]) {
          cookInfo[i] = 1;
        }
        cookInfo[i]++;
      });
    });
    const cookUseMap = Object.keys(cookInfo).map((item) => {
      return { name: item, time: cookInfo[item] };
    }).sort((a, b) => b.time - a.time);
    const len = cookUseMap.length;
    if (!len) {
      await session.send(at + "还没有统计到任何数据...");
      return;
    }
    const frontRowList = cookUseMap.slice(0, 3).filter((item) => item);
    const msg = `至今为止,所有用户已经查询了${len}个菜谱的详细教程。
其中查询最多的菜谱和它的次数为:

${frontRowList.map((item) => {
      return `[${item.name}] ${item.time}次`;
    }).join("\n")}`;
    await session.send(at + msg);
  });
  ctx.command("做菜指南/做菜历史").action(async ({ session }) => {
    let at = "";
    if (config.atQQ) {
      at = `<at id="${session.userId}" />`;
    }
    if (!cookingTool.historyList[session.userId] || cookingTool.historyList[session.userId].length == 0) {
      await session.send(at + "您最近还没指定做菜的教程详细去做过菜呢...");
      return;
    }
    const useCookHistoryList = cookingTool.historyList[session.userId].reverse();
    await session.send(at + `您最近的做菜记录为 (只展示最近10条):
${useCookHistoryList.length > 10 ? useCookHistoryList.slice(0, 10).join("、") + "..." : useCookHistoryList.join("、")}`);
  });
  ctx.command("做菜指南/做菜 <cookName>").action(async ({ session }, cookName) => {
    let at = "";
    if (config.atQQ) {
      at = `<at id="${session.userId}" />`;
    }
    if (!cookName) {
      await session.send(at + "(❁´◡`❁) 现在你想做什么吃的?\n(10 秒内发送想吃的菜名,获取教程 或者说 否 结束询问)");
      cookName = await session.prompt(1e4);
      if (!cookName || cookName == "否") {
        return;
      }
    }
    if (throttle.isUse(session.userId)) {
      await session.send(at + "请等待上一个请求...");
      return;
    }
    throttle.start(session.userId);
    const result = await cookingTool.getMenuList(cookName, session.userId);
    console.log(result);

    if (!result.code) {
      await session.send(at + result.msg);
      throttle.end(session.userId);
      return;
    }
    for (let i = 0; i < result.msgList.length; i++) {
      await session.send(result.msgList[i].pic + "\n[" + result.msgList[i].index + "] " + result.msgList[i].name);
    }
    await session.send(at + `目前给您列举了上面菜的制作教程,您想选择哪个教程继续呢?
tip:20 秒内填对应 1~${result.msgList.length} 序号做选择`);
    const index = Number(await session.prompt(20000));
    if (isNaN(index) || index < 1 || index > result.msgList.length) {
      throttle.end(session.userId);
      return;
    }
    if (ctx.puppeteer && config.useHTML) {
      await session.send(at + await cookingTool.formatDetailByHTML(session.userId, index - 1))
    } else {
      await session.send(at + await cookingTool.formatDetailByText(session.userId, index - 1));
    }
    throttle.end(session.userId);
  });
  ctx.on("ready", () => {
    cookingTool.intData();
  });
  const throttle = {
    userList: {},
    isUse(userId) {
      if (this.userList[userId] === void 0) {
        this.userList[userId] = false;
      }
      return this.userList[userId];
    },
    start(userId) {
      this.userList[userId] = true;
    },
    end(userId) {
      this.userList[userId] = false;
    }
  };
}

[/collapse]

[collapse title="html.ts"]

//html.ts
export const html = {
    detail(data: any) {
        return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>菜谱</title>
    <style>
        body,
        html {
            width: 800px;
            flex-direction: column;
            display: flex;
            justify-content: center;
            align-items: center;
            font-family: Arial, sans-serif;
            background-color: #f9f9f9;
            color: #333;
            margin: 0;
            padding: 0;
            background-color: transparent;
        }

        .header {
            background-color: #e74c3c;
            padding: 20px;
            text-align: center;
            color: #fff;
        }

        .header h1 {
            margin: 0;
            font-size: 2em;
        }

        .search-box {
            margin-top: 10px;
            display: flex;
            justify-content: center;
        }

        .search-box input[type="text"] {
            padding: 10px;
            width: 300px;
            border: none;
            border-radius: 4px 0 0 4px;
            font-size: 1em;
        }

        .search-box button {
            padding: 10px 20px;
            background-color: #fff;
            color: #e74c3c;
            border: none;
            border-radius: 0 4px 4px 0;
            cursor: pointer;
            font-size: 1em;
        }

        .search-box button:hover {
            background-color: #f1f1f1;
        }

        .container {
            width: 100%;
            box-sizing: border-box;
            padding: 20px;
            background-color: #fff;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            border-radius: 8px;
        }

        .recipe-image {
            text-align: center;
            margin-bottom: 20px;
        }

        .recipe-image img {
            max-width: 100%;
            border-radius: 8px;
        }

        h2 {
            color: #e74c3c;
            border-bottom: 2px solid #e74c3c;
            padding-bottom: 5px;
        }

        ul,
        ol {
            padding-left: 20px;
        }

        ul li,
        ol li {
            margin-bottom: 10px;
        }

        .footer {
            text-align: center;
            margin-top: 20px;
            font-size: 0.9em;
            color: #777;
        }
    </style>
</head>

<body>
    <div class="container">
        <h1 id="recipeTitle" style="text-align: center;">${data.name}</h1>
        <div class="recipe-image">
            <img src="${data.image}">
        </div>

        <div class="content">
            <h2>食材</h2>
            <ul>
                ${data.materials.map((item) => {
            return `<li>${item}</li>`
        }).join('')}
            </ul>

            <h2>步骤</h2>
            <ol>
            ${data.practice.map((item) => {
                return `<li>${item}</li>`
            }).join('')}
            </ol>
        </div>
    </div>
</body>

</html>
        `
    }
}

[/collapse]

插件提供了四个指令,前三个是最后一个的子指令,最后一个起到一个类似help的作用

  • 做菜(做菜 <cookName> 接受一个可选参数)
  • 做菜历史
  • 做菜统计
  • 做菜指南

如果不动源代码的话使用起来大概是下面的这样

——正常来说理应在做菜后发送菜品名称就能正常返回菜谱

然而看了后台发现koishi端报错如下

2026-03-04 21:29:15 [W] session Error: Error with request send_group_msg, args: {"group_id":995010140,"message":[{"type":"image","data":{"file":"file:///Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/data/image/1772630954624.jpg","cache":0}},{"type":"text","data":{"text":"[1] 紫菜鸡蛋汤"}}]}, retcode: 1200
                            at _Internal._get (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/koishi-plugin-adapter-onebot/lib/index.js:119:11)
                            at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
                            at async _Internal.<computed> [as sendGroupMsg] (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/koishi-plugin-adapter-onebot/lib/index.js:162:20)
                            at async OneBotMessageEncoder.flush (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/koishi-plugin-adapter-onebot/lib/index.js:771:338)
                            at async OneBotMessageEncoder.send (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/@satorijs/core/lib/index.cjs:754:5)
                            at async Proxy.sendMessage (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/@satorijs/core/lib/index.cjs:486:22)
                            at async _Command.<anonymous> (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/external/smmcat-faqcooking/src/index.ts:249:7)
                            at async Array.<anonymous> (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/@koishijs/core/src/command/command.ts:291:14)
                            at async _Command.execute (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/@koishijs/core/src/command/command.ts:307:22)
                            at async <anonymous> (/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/node_modules/@koishijs/core/src/session.ts:429:22)

napcat(onebot实现端)报错如下

03-04 21:29:15 [error] MeNtha | 发生错误 Error: ENOENT: no such file or directory, copyfile '/Users/mitorimatsumoto/Documents/bot/mentha/koishi-app/data/image/1772630954624.jpg' -> '/app/.config/QQ/NapCat/temp/2bc15a2b-1025-44d4-8952-95c920b2edd3.jpg'
    at OneBotMsgApi.handleOb11FileLikeMessage (file:///app/napcat/napcat.mjs:108996:13)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async image (file:///app/napcat/napcat.mjs:108540:10)
    at async Promise.all (index 0)
    at async OneBotMsgApi.createSendElements (file:///app/napcat/napcat.mjs:108935:17)
    at async SendGroupMsg.base_handle (file:///app/napcat/napcat.mjs:109421:52)
    at async SendGroupMsg.websocketHandle (file:///app/napcat/napcat.mjs:64081:23)
    at async OB11WebSocketServerAdapter.handleMessage (file:///app/napcat/napcat.mjs:96203:21)

从napcat的日志可以很简单地看出:napcat目前运行在容器里,是隔离环境,当然读不到外部koishi的文件内容。然后通过koishi端的日志看出,发送文件是图片,考虑上传云储存然后生成url会比较有泛用性,先姑且放到todo里,明明还没开始读源码

抛开图片发不出来这件事,来看一下每个指令的实现

做菜指南/做菜 <cookName>

指令限制每个用户一次只能进行一次查询,通过定义一个throttle对象来管理用户池,提供一个查询isUse(userId)、以及加入start(userId)和退出end(userId),做了一下并发控制

主要逻辑通过cookingTool处理,

  • 一个userIdList: {}用于存放一次的查询结果
  • 一个historyList: {}用于存放搜索记录用于统计
  • 实现了一个intData()用于初始化
  • getData(cookingName, userId)负责向远端接口获取菜谱数据
  • getMenuList(cookingName, userId)返回候选菜谱列表
  • formatMenuByText(data)formatDetailByText(userId, index)formatDetailByHTML(userId, index)做一些结构化的操作,
  • setLocatStorehistory(userId, cookName)存储历史记录

整体逻辑很简单,重点就是通过https://tools.mgtv100.com/external/v1/pear/cookbook这个接口拿菜谱信息,查了一下接口使用方法,post带一个{ search_food: cookingName }就能返回菜谱内容,结果用postman测了一下发现api突然死了,好尴尬由于这个接口似乎不是很稳定,所以待会儿应该会顺带换一个接口,最后测试的时候发现又活了,用postman测试结果如下

拿到数据以后结构化处理一下就能返回,koishi安装了puppeteer依赖和开启html配置的时候就会调用formatDetailByHTML来生成图片结果,否则就formatDetailByText生成文本结果。html的具体内容就在html.ts中,用内联样式处理一下然后交给puppeteer截图输出就行

做菜指南/做菜统计

从historyList取出来放到临时对象里进行排序以及统计,最后输出搜索次数前三的菜谱

不过第一次知道js的sort的cmp是这么写的⬇️一直以为是像cpp一样写大于小于

    const cookUseMap = Object.keys(cookInfo).map((item) => {
      return { name: item, time: cookInfo[item] };
    }).sort((a, b) => b.time - a.time);

测试的时候发现一个严重的问题,setLocatStorehistory()只在文本结构化的分支里被调用......这就导致了一旦开启html截图输出就不会记录任何历史数据,有点离谱了

更弱智的是,它统计操作时少写了一个else,直接导致所有统计数据次数多了1 ・゚( ノヮ´ )

做菜指南/做菜历史

和上面类似,同样从historyList取出,不过不需要做统计操作就是了


综上,比较明显的问题就是图片发送和接口的问题了,可能后面会看情况做一些更好的交互

下面开始进行二次开发

二次开发

图片发送

朋友搭了一个s3对象存储服务暴露了一个api给我,那不得不用了

使用方法很简单,依赖一个aws-sdk库。在webui上把bucket预先建好,上传时new一个S3Client,用client的send方法指定存储的bucket就可以上传,上传后会返回一个url。考虑到安全性,可以给url设定一个签名过期时间,毕竟这些图片几乎是一次性的

先把工具函数写好

//minio.ts
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const client = new S3Client({
    region: process.env.MINIO_REGION,
    endpoint: process.env.MINIO_ENDPOINT,
    credentials: {
        accessKeyId: process.env.MINIO_ACCESS_KEY,
        secretAccessKey: process.env.MINIO_SECRET_KEY,
    },
    forcePathStyle: true,
    /*
    forcePathStyle: true http://localhost:9000/my-bucket/my-object
    forcePathStyle: false http://my-bucket.localhost:9000/my-object
     */
})

const config = {
    endpoint: process.env.MINIO_ENDPOINT,
    bucket: process.env.MINIO_BUCKET,
    accessKeyId: process.env.MINIO_ACCESS_KEY,
    secretAccessKey: process.env.MINIO_SECRET_KEY,
    expiresIn: Number(process.env.MINIO_SIGN_EXPIRES) || 600,
    region: process.env.MINIO_REGION
}

export function buildObjectKey(picId: number, timestamp = Date.now()) {
    const date = new Date(timestamp)
    const year = date.getFullYear()
    const month = String(date.getMonth() + 1).padStart(2, '0')
    const day = String(date.getDate()).padStart(2, '0')
    return `${year}/${month}/${day}/cooking-pic-${picId}-${timestamp}.jpg`
}


export async function uploadToMinio(buffer: Buffer, picId: number) {
    const objectKey = buildObjectKey(picId)
    const timestamp = Date.now()
    await client.send(new PutObjectCommand({
        Bucket: config.bucket,
        Key: objectKey,
        Body: buffer,
        ContentType: 'image/jpeg',
        ContentDisposition: `attachment; filename="cooking-pic-${picId}.jpg"`,
        Metadata: {
            'pic-id': String(picId)
        },
    }))

    const signedUrl = await getSignedUrl(client, new GetObjectCommand({
        Bucket: config.bucket,
        Key: objectKey,
    }), { expiresIn: config.expiresIn })

    console.log(`图片上传成功,访问链接 ${signedUrl}`)
    return {
        bucket: config.bucket,
        key: objectKey,
        url: signedUrl,
        expireAt: timestamp + config.expiresIn * 1000,
        expireIn: config.expiresIn,
        endpoint: config.endpoint
    }
}

主函数里有两处需要上传图片,一处是从api处获取的图片、一处是html截图,分别打补丁修改

接口图片

 // 从远端图片链接获取 Buffer,准备上传到 MinIO。  
    async getImageBuffer(imageUrl: string): Promise<Buffer> {
      const response = await ctx.http.get(imageUrl, { responseType: 'arraybuffer' });
      return Buffer.from(response);
    },

    // 向远端菜谱接口请求数据,并按用户隔离缓存当前查询结果。
    async getData(cookingName, userId) {
      try {
        const res = await ctx.http.post(baseUrl, { search_food: cookingName });
        if (res.code !== 200)
          return this.userIdList[userId] = null;
        this.userIdList[userId] = res.data;
        if (config.download) {
          // 开启 download 时,会先把所有候选菜谱图片缓存到本地,避免直接引用远程图。
          const eventList = res.data.map((item, index) => {
            return new Promise(async (resolve) => {
              try {
                //res.data[index].image = await tool.setStoreForImage(item.image)
                const buffer=await this.getImageBuffer(item.image);
                const uploaded=await uploadToMinio(buffer, index);
                res.data[index].image=uploaded.url;
                resolve(true)
              } catch (error) {
                console.log(error);
                resolve(true)
              }
            })
          })
          await Promise.all(eventList)
        }
      } catch (error) {
        this.userIdList[userId] = null;
      }
    },

html截图

注意puppeteer导出的不是buffer是string,必须要走一步解析转成buffer

    //现在返回的是整个payload
    async formatDetailByHTML(userId, index) {
      const cookItem = this.userIdList[userId]?.[index];
      if (!cookItem) return;
      const strHtml = html.detail(cookItem)
      const imgseg = h.parse(await ctx.puppeteer.render(strHtml))[0]
      const src=imgseg?.attrs?.src as string
      if(!src){
        return {error:"HTML渲染失败,无法获取图片链接。"}
      }
      const result=Buffer.from(src.split(",")[1],"base64")
      if ((result as any).error) return result
      const payload = result as any
      try {
        // 统一用同一个 docx buffer 上传到 MinIO,避免再依赖群文件接口。
        const uploaded = await uploadToMinio(payload.buffer as Buffer, payload.filename as number)
        return {
          ...payload,
          ...uploaded,
        }
      } catch (error) {
        const message = (error as Error)?.message ?? '未知错误。'
        if (payload.filePath) {
          return { error: `${message}\n本地备份已保留:${payload.filePath}` }
        }
        return { error: message }
      }
    },

...

if (ctx.puppeteer && config.useHTML) {
      const exported = await cookingTool.formatDetailByHTML(session.userId, index - 1)
      if ((exported as any).error) {
        await session.send(at + `导出失败:${(exported as any).error}`)
        return
      }
      else {
        await session.send(at + "教程详情如下图所示:");
        await session.send(h.image(exported.url))
      }
    } else {
      await session.send(at + await cookingTool.formatDetailByText(session.userId, index - 1));
    }
 

现在的结果就很好了

不过处理速度稍微慢了一点,也没有正在获取的提示词,于是稍微做了一点点交互上的修改和html的美化

统计修复

问题不是很难修,主要是一些粗心大意的问题,直接上代码


 async formatDetailByHTML(userId, index) {

...

    try {
        // 统一用同一个 docx buffer 上传到 MinIO,避免再依赖群文件接口。
        this.setLocatStorehistory(userId, cookItem.name);
        const uploaded = await uploadToMinio(payload.buffer as Buffer, payload.filename as number)
        return {
          ...payload,
          ...uploaded,
        }
      } catch (error) {
        const message = (error as Error)?.message ?? '未知错误。'
        if (payload.filePath) {
          return { error: `${message}\n本地备份已保留:${payload.filePath}` }
        }
        return { error: message }
      }
}

...

//补充一个else就行
    ...
    const cookInfo = {};
    Object.values(cookingTool.historyList).forEach((item: any) => {
      item.forEach((i) => {
        if (!cookInfo[i]) {
          cookInfo[i] = 1;
        }
        else cookInfo[i]++;
      });
    });

鲁棒性维护

在测试的时候发生的奇怪问题,不是很好复现,情况如下

  • 生成图片的时候puppeteer侧?偶发报错导致和html的连接失败,生成图片失败
  • 由于流程问题,在生成图片的地方抛错就会无法进入throttle.end(),导致无法退出用户池

puppeteer的报错无法控制,那么只能考虑用try catch包裹了,然后交给finally统一throttle.end()

最后是完整的做菜指令代码

ctx.command("做菜指南/做菜 <cookName>").action(async ({ session }, cookName) => {
    let at = "";
    if (config.atQQ) {
      at = `<at id="${session.userId}" />`;
    }
    if (!cookName) {
      await session.send(at + "(❁´◡`❁) 现在你想做什么吃的?\n(10 秒内发送想吃的菜名,获取教程 或者说 否 结束询问)");
      cookName = await session.prompt(1e4);
      if (!cookName || cookName == "否") {
        await session.send(at + "已过期,下次记得早点告诉我你想吃什么哦~");
        return;
      }
    }
    if (throttle.isUse(session.userId)) {
      await session.send(at + "请等待上一个请求...");
      return;
    }
    session.send(at + `正在搜索关于 ${cookName} 的菜谱...`);
    // 同一用户一次只允许进行一轮“搜菜 -> 选序号 -> 看详情”,防止 prompt 串台。
    throttle.start(session.userId);
    try {
    const result = await cookingTool.getMenuList(cookName, session.userId);
    console.log(result);

    if (!result.code) {
      await session.send(at + result.msg);
      return;
    }
    for (let i = 0; i < result.msgList.length; i++) {
      await session.send(result.msgList[i].pic + "\n[" + result.msgList[i].index + "] " + result.msgList[i].name);
    }
    await session.send(at + `目前给您列举了上面菜的制作教程,您想选择哪个教程继续呢?
tip:20 秒内填对应 1~${result.msgList.length} 序号做选择`);
    const index = Number(await session.prompt(20000));
    if (isNaN(index) || index < 1 || index > result.msgList.length) {
      await session.send(at + "已过期,下次记得早点告诉我你想吃什么哦~");
      return;
    }
    if (ctx.puppeteer && config.useHTML) {
      await session.send(at + "教程详情图生成中...");
      const exported = await cookingTool.formatDetailByHTML(session.userId, index - 1)
      if ((exported as any).error) {
        await session.send(at + `导出失败:${(exported as any).error}`)
        return
      }
      else {
        
        await session.send(h.image(exported.url))
      }
    } else {
      await session.send(at + await cookingTool.formatDetailByText(session.userId, index - 1));
    }} catch (error) {
      await session.send(at + "请求过程中发生错误,请稍后再试~");
    } finally {
    throttle.end(session.userId);
    }
  });

最终测试

正常流程

做菜失败(无法找到/超时)

做菜统计

做菜历史

异常处理


结语

这次的项目总体来说还算是比较简单的,没有涉及到非常复杂的内容,而且源代码结构也还算比较清晰,复用性也很高,改起来比较舒服

难点理论上来说是接s3服务的部分,不过由于先前有过搭建类似服务的经验,所以根据上面展示的模板来搭建就会很简单

耗费时间比较久的还是错误处理的地方,由于源代码错误处理并不是很好(真的做错误处理了吗)所以很多内容基本上是从零开始重构。尤其是puppeteer的处理,一开始没用过这个库也不知道它最后会返回一个string,还以为会正常返回buffer,要走一步解析

整体来说,做这个类似逆向的工作有意思的点还是在阅读别人的源码上,有的时候会思考如果是自己来重构的话可能会有更好的解决方案,但是如果是在原项目上开刀可能需要使用更保守复用性更高的写法(能跑就别动。jpg),而且再加上自己代码习惯不是很好,读代码能学到一些更规范化的操作比如把一堆工具函数打包丢到一个工具类里,以前打竞赛代码感觉这么做简直是脱裤子放屁现在我错了我一定好好写

其实关于菜谱,之前有一个很火的开源项目https://cook.yunyoujun.cn/about,仓库地址https://github.com/YunYouJun/cook,原本打算clone这个仓库然后试试看能不能暴露一下api给bot用,那这样可玩性就会很高了。不过精力有限,下次可以再单开一个项目自己试试。

posted @ 2026-03-05 19:25  Mitori  阅读(74)  评论(7)    收藏  举报