feat(cook): merge @cook/types into @yunyoujun/cook, add CLI search & skill
- Merge packages/types into packages/cook/src/types - Add CLI search command with ingredient alias support - Add build config (unbuild) and GitHub Actions publish workflow - Add Cook Skill documentation (SKILL.md, README.md) - Update all imports from @cook/types to @yunyoujun/cook - Bump @nuxt/test-utils 4.0.0 → 4.0.2, update lastDbUpdated
This commit is contained in:
1
.codebuddy/skills/cook
Symbolic link
1
.codebuddy/skills/cook
Symbolic link
@@ -0,0 +1 @@
|
||||
../../skills/cook
|
||||
36
.github/workflows/publish-cook.yml
vendored
Normal file
36
.github/workflows/publish-cook.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Publish @yunyoujun/cook
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cook@*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Use Node.js LTS
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
registry-url: https://registry.npmjs.org/
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm -C packages/cook run build
|
||||
|
||||
- name: Publish to npm
|
||||
run: pnpm -C packages/cook publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -2,7 +2,7 @@ export const appName = '食用手册'
|
||||
export const appDescription = '好的,今天我们来做菜!'
|
||||
|
||||
export const namespace = 'cook'
|
||||
export const lastDbUpdated = '2026-3-26 16:45:17'
|
||||
export const lastDbUpdated = '2026-4-10 11:40:08'
|
||||
|
||||
export const icp = '苏ICP备17038157号'
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type { Cookbook } from '@cook/types/cookbook'
|
||||
export type { Cookbook } from '@yunyoujun/cook'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type { IncompatibleRule } from '@cook/types/incompatible'
|
||||
export type { IncompatibleRule } from '@yunyoujun/cook'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type { Difficulty, RecipeItem, Recipes, StuffItem } from '@cook/types/recipe'
|
||||
export type { Difficulty, RecipeItem, Recipes, StuffItem } from '@yunyoujun/cook'
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shikijs/vitepress-twoslash": "^3.23.0",
|
||||
"sass": "^1.98.0",
|
||||
"sass": "^1.99.0",
|
||||
"typedoc": "^0.28.18",
|
||||
"typedoc-plugin-markdown": "^4.11.0",
|
||||
"typedoc-vitepress-theme": "^1.1.2",
|
||||
"unocss": "^66.6.7",
|
||||
"unocss": "^66.6.8",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vitepress": "^2.0.0-alpha.17",
|
||||
|
||||
@@ -102,7 +102,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
alias: {
|
||||
'@cook/types': './packages/types/src',
|
||||
'@cook/types': './packages/cook/src/types',
|
||||
},
|
||||
|
||||
future: {
|
||||
|
||||
33
package.json
33
package.json
@@ -44,13 +44,14 @@
|
||||
"@capacitor/ios": "7.4.3",
|
||||
"@capacitor/keyboard": "7.0.3",
|
||||
"@capacitor/status-bar": "7.0.3",
|
||||
"@yunyoujun/cook": "workspace:*",
|
||||
"dayjs": "^1.11.20",
|
||||
"vue-about-me": "^1.4.0",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^7.7.3",
|
||||
"@capacitor/android": "^8.2.0",
|
||||
"@antfu/eslint-config": "^8.1.1",
|
||||
"@capacitor/android": "^8.3.0",
|
||||
"@capacitor/cli": "7.4.3",
|
||||
"@capacitor/dialog": "^8.0.1",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
@@ -64,45 +65,45 @@
|
||||
"@nuxt/devtools": "^3.2.4",
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@nuxt/scripts": "^0.13.2",
|
||||
"@nuxt/test-utils": "^4.0.0",
|
||||
"@nuxt/test-utils": "^4.0.2",
|
||||
"@nuxtjs/color-mode": "^4.0.0",
|
||||
"@nuxtjs/ionic": "1.0.2",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@pinia/testing": "^1.0.3",
|
||||
"@types/node": "^25.5.0",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@unocss/eslint-config": "^66.6.7",
|
||||
"@unocss/nuxt": "^66.6.7",
|
||||
"@types/node": "^25.5.2",
|
||||
"@unhead/vue": "^2.1.13",
|
||||
"@unocss/eslint-config": "^66.6.8",
|
||||
"@unocss/nuxt": "^66.6.8",
|
||||
"@vite-pwa/nuxt": "^1.1.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"@yunlefun/vue": "^0.1.1",
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"baseline-browser-mapping": "^2.10.17",
|
||||
"bumpp": "^11.0.1",
|
||||
"consola": "^3.4.2",
|
||||
"dexie": "^4.4.1",
|
||||
"eslint": "^10.1.0",
|
||||
"dexie": "^4.4.2",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"jsdom": "^29.0.2",
|
||||
"lint-staged": "^16.4.0",
|
||||
"nuxt": "^4.4.2",
|
||||
"pinia": "^3.0.4",
|
||||
"sass": "^1.98.0",
|
||||
"sass": "^1.99.0",
|
||||
"serve": "^14.2.6",
|
||||
"simple-git": "^3.33.0",
|
||||
"simple-git": "^3.35.2",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"star-markdown-css": "^0.5.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.6.7",
|
||||
"vitest": "^4.1.2",
|
||||
"unocss": "^66.6.8",
|
||||
"vitest": "^4.1.4",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"unplugin": "^3.0.0",
|
||||
"vite": "^8.0.3"
|
||||
"vite": "^8.0.8"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# @cook/cli
|
||||
# @yunyoujun/cook
|
||||
|
||||
Cook CLI 工具 - 用于管理菜谱数据的命令行工具。
|
||||
Cook CLI 工具 - 用于管理菜谱数据和检索菜谱的命令行工具。
|
||||
|
||||
## 功能
|
||||
|
||||
- 🔍 按食材/厨具/难度/标签/做法检索菜谱(支持食材别名)
|
||||
- 🚀 从飞书 Wiki 拉取菜谱数据
|
||||
- 📝 CSV ↔ JSON 双向转换
|
||||
- ✅ 完整的单元测试覆盖
|
||||
@@ -11,6 +12,30 @@ Cook CLI 工具 - 用于管理菜谱数据的命令行工具。
|
||||
|
||||
## 命令
|
||||
|
||||
### search
|
||||
|
||||
检索菜谱(支持 AI Skill 调用):
|
||||
|
||||
```bash
|
||||
# 按食材搜索
|
||||
pnpm search --stuff "鸡蛋,番茄"
|
||||
|
||||
# 组合筛选
|
||||
pnpm search --stuff "鸡蛋,番茄" --tool "电饭煲" --difficulty "简单"
|
||||
|
||||
# 支持别名(西红柿 → 番茄)
|
||||
pnpm search --stuff "鸡蛋,西红柿"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
- `--stuff <items>`: 食材,逗号/顿号分隔
|
||||
- `--tool <tool>`: 厨具(电饭煲/烤箱/空气炸锅/微波炉/一口大锅)
|
||||
- `--difficulty <level>`: 难度(简单/普通/困难)
|
||||
- `--tag <tag>`: 标签(懒人/下饭/减脂 等)
|
||||
- `--method <method>`: 做法(炒/煎/蒸/煮/烤/炸 等)
|
||||
- `--limit <n>`: 最大返回数(默认 10)
|
||||
|
||||
### fetch
|
||||
|
||||
从飞书 Wiki 拉取最新菜谱数据:
|
||||
@@ -113,12 +138,15 @@ packages/cook/
|
||||
├── src/
|
||||
│ ├── commands/
|
||||
│ │ ├── fetch.ts # 飞书数据拉取
|
||||
│ │ └── convert.ts # CSV 转 JSON
|
||||
│ │ ├── convert.ts # CSV 转 JSON
|
||||
│ │ └── search.ts # 菜谱检索
|
||||
│ ├── utils/
|
||||
│ │ ├── alias.ts # 食材别名映射
|
||||
│ │ ├── config.ts # 配置和常量
|
||||
│ │ ├── csv.ts # CSV 处理工具
|
||||
│ │ ├── csv.test.ts # CSV 测试
|
||||
│ │ └── feishu.ts # 飞书 API 封装
|
||||
│ │ ├── feishu.ts # 飞书 API 封装
|
||||
│ │ └── search.ts # 检索核心逻辑
|
||||
│ └── index.ts # CLI 入口
|
||||
├── types.ts # 类型定义
|
||||
├── package.json
|
||||
|
||||
13
packages/cook/build.config.ts
Normal file
13
packages/cook/build.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineBuildConfig } from 'unbuild'
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'src/index',
|
||||
'src/cli',
|
||||
],
|
||||
clean: true,
|
||||
declaration: true,
|
||||
rollup: {
|
||||
emitCJS: false,
|
||||
},
|
||||
})
|
||||
@@ -1,27 +1,61 @@
|
||||
{
|
||||
"name": "@cook/cli",
|
||||
"name": "@yunyoujun/cook",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"cook": "./src/index.ts"
|
||||
"description": "Cook CLI - 食用手册命令行工具,菜谱检索/数据管理",
|
||||
"author": "YunYouJun <me@yunyoujun.cn>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/YunYouJun/cook/tree/main/packages/cook#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/YunYouJun/cook.git",
|
||||
"directory": "packages/cook"
|
||||
},
|
||||
"bugs": "https://github.com/YunYouJun/cook/issues",
|
||||
"keywords": [
|
||||
"cook",
|
||||
"recipe",
|
||||
"cli",
|
||||
"菜谱",
|
||||
"食谱"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"bin": {
|
||||
"cook": "./dist/cli.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"cook": "tsx src/index.ts",
|
||||
"fetch": "tsx src/index.ts fetch",
|
||||
"convert": "tsx src/index.ts convert"
|
||||
"build": "unbuild",
|
||||
"cook": "tsx src/cli.ts",
|
||||
"fetch": "tsx src/cli.ts fetch",
|
||||
"convert": "tsx src/cli.ts convert",
|
||||
"search": "tsx src/cli.ts search",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
"@cook/types": "workspace:*",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@larksuiteoapi/node-sdk": "^1.60.0",
|
||||
"cac": "^7.0.0",
|
||||
"consola": "^3.4.2",
|
||||
"papaparse": "^5.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"tsx": "^4.21.0"
|
||||
"tsx": "^4.21.0",
|
||||
"unbuild": "^3.6.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
67
packages/cook/src/cli.ts
Normal file
67
packages/cook/src/cli.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import cac from 'cac'
|
||||
import { version } from '../package.json'
|
||||
import { runConvert } from './commands/convert'
|
||||
import { envFile } from './utils/config'
|
||||
|
||||
const LINE_BREAK_RE = /\r?\n/
|
||||
|
||||
// 加载 .env 文件(Node 20.6+ 支持 --env-file,但这里手动加载兼容性更好)
|
||||
function loadEnv() {
|
||||
try {
|
||||
if (!fs.existsSync(envFile))
|
||||
return
|
||||
const content = fs.readFileSync(envFile, 'utf-8')
|
||||
for (const line of content.split(LINE_BREAK_RE)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
continue
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (eqIndex === -1)
|
||||
continue
|
||||
const key = trimmed.slice(0, eqIndex).trim()
|
||||
const value = trimmed.slice(eqIndex + 1).trim()
|
||||
if (key && !(key in process.env)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const cli = cac('cook')
|
||||
|
||||
cli
|
||||
.command('convert', '将本地 CSV 数据转换为 JSON')
|
||||
.action(() => {
|
||||
runConvert()
|
||||
})
|
||||
|
||||
cli
|
||||
.command('fetch', '从飞书拉取菜谱数据并生成 CSV + JSON')
|
||||
.action(async () => {
|
||||
const { runFetch } = await import('./commands/fetch')
|
||||
await runFetch()
|
||||
})
|
||||
|
||||
cli
|
||||
.command('search', '检索菜谱(供 AI Skill 或脚本调用)')
|
||||
.option('--stuff <items>', '食材,逗号分隔(如 "鸡蛋,番茄")')
|
||||
.option('--tool <tool>', '厨具(如 "电饭煲")')
|
||||
.option('--difficulty <level>', '难度:简单/普通/困难')
|
||||
.option('--tag <tag>', '标签(如 "懒人")')
|
||||
.option('--method <method>', '做法(如 "炒")')
|
||||
.option('--limit <n>', '最大返回数(默认 10)')
|
||||
.option('--json', '输出原始 JSON(供 AI/脚本消费)')
|
||||
.action(async (opts) => {
|
||||
const { runSearch } = await import('./commands/search')
|
||||
runSearch(opts)
|
||||
})
|
||||
|
||||
cli.help()
|
||||
cli.version(version)
|
||||
cli.parse()
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RecipeItem } from '../../types.js'
|
||||
import type { RecipeItem } from '../types/index.js'
|
||||
import fs from 'node:fs'
|
||||
import consola from 'consola'
|
||||
import {
|
||||
|
||||
95
packages/cook/src/commands/search.ts
Normal file
95
packages/cook/src/commands/search.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { SearchOptions } from '../utils/search.js'
|
||||
import fs from 'node:fs'
|
||||
import consola from 'consola'
|
||||
import { colors } from 'consola/utils'
|
||||
import {
|
||||
incompatibleFoodsCsvFile,
|
||||
recipeCsvFile,
|
||||
} from '../utils/config.js'
|
||||
import { parseIncompatibleFoodsCsv, parseRecipeCsv } from '../utils/csv.js'
|
||||
import { searchRecipes } from '../utils/search.js'
|
||||
|
||||
export type { SearchOptions }
|
||||
|
||||
export interface SearchCommandOptions {
|
||||
stuff?: string
|
||||
tool?: string
|
||||
difficulty?: string
|
||||
tag?: string
|
||||
method?: string
|
||||
limit?: number
|
||||
/** 输出原始 JSON(供 AI/脚本消费) */
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
const STUFF_SPLIT_RE = /[,,、]/
|
||||
|
||||
export function runSearch(opts: SearchCommandOptions) {
|
||||
const recipeCsv = fs.readFileSync(recipeCsvFile, 'utf-8')
|
||||
const recipes = parseRecipeCsv(recipeCsv)
|
||||
|
||||
let incompatible: ReturnType<typeof parseIncompatibleFoodsCsv> = []
|
||||
try {
|
||||
const incompatibleCsv = fs.readFileSync(incompatibleFoodsCsvFile, 'utf-8')
|
||||
incompatible = parseIncompatibleFoodsCsv(incompatibleCsv)
|
||||
}
|
||||
catch {
|
||||
consola.warn('未找到食物相克数据,跳过相克检查')
|
||||
}
|
||||
|
||||
const options: SearchOptions = {
|
||||
stuff: opts.stuff?.split(STUFF_SPLIT_RE).filter(Boolean),
|
||||
tool: opts.tool,
|
||||
difficulty: opts.difficulty,
|
||||
tag: opts.tag,
|
||||
method: opts.method,
|
||||
limit: opts.limit ?? 10,
|
||||
}
|
||||
|
||||
const result = searchRecipes(recipes, incompatible, options)
|
||||
|
||||
// JSON 模式:供 AI Skill / 脚本消费
|
||||
if (opts.json) {
|
||||
consola.log(JSON.stringify(result, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
// 美化输出模式
|
||||
if (result.recipes.length === 0) {
|
||||
consola.info('未找到匹配的菜谱')
|
||||
return
|
||||
}
|
||||
|
||||
consola.success(`找到 ${colors.green(String(result.recipes.length))} 道菜谱:\n`)
|
||||
|
||||
for (const r of result.recipes) {
|
||||
const difficulty = r.difficulty ? colors.dim(` · ${r.difficulty}`) : ''
|
||||
const tags = r.tags?.length ? colors.dim(` · ${r.tags.join('、')}`) : ''
|
||||
consola.log(` ${colors.bold(r.name)}${difficulty}${tags}`)
|
||||
|
||||
const stuffStr = r.stuff.map((s) => {
|
||||
return r.matchedStuff.includes(s)
|
||||
? colors.green(s)
|
||||
: colors.dim(s)
|
||||
}).join('、')
|
||||
consola.log(` 食材:${stuffStr}`)
|
||||
|
||||
if (r.matchedStuff.length > 0 && r.missingStuff.length > 0) {
|
||||
consola.log(` ${colors.green(`✓ 你有 ${r.matchedStuff.length} 样`)}${colors.yellow(`,还需 ${r.missingStuff.join('、')}`)}`)
|
||||
}
|
||||
else if (r.missingStuff.length === 0) {
|
||||
consola.log(` ${colors.green('✓ 食材全部齐全')}`)
|
||||
}
|
||||
|
||||
if (r.videoUrl) {
|
||||
consola.log(` ${colors.cyan(`📺 ${r.videoUrl}`)}`)
|
||||
}
|
||||
consola.log('')
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
for (const w of result.warnings) {
|
||||
consola.warn(`⚠️ ${colors.yellow(`${w.foodA} + ${w.foodB}`)}:${w.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,9 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import cac from 'cac'
|
||||
import { version } from '../package.json'
|
||||
import { runConvert } from './commands/convert.js'
|
||||
import { envFile } from './utils/config.js'
|
||||
// types
|
||||
export * from './types'
|
||||
|
||||
const LINE_BREAK_RE = /\r?\n/
|
||||
// utils
|
||||
export { normalizeStuff } from './utils/alias'
|
||||
export { cleanBv, parseIncompatibleFoodsCsv, parseRecipeCsv, recipesToCsv } from './utils/csv'
|
||||
export { searchRecipes } from './utils/search'
|
||||
|
||||
// 加载 .env 文件(Node 20.6+ 支持 --env-file,但这里手动加载兼容性更好)
|
||||
function loadEnv() {
|
||||
try {
|
||||
if (!fs.existsSync(envFile))
|
||||
return
|
||||
const content = fs.readFileSync(envFile, 'utf-8')
|
||||
for (const line of content.split(LINE_BREAK_RE)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
continue
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (eqIndex === -1)
|
||||
continue
|
||||
const key = trimmed.slice(0, eqIndex).trim()
|
||||
const value = trimmed.slice(eqIndex + 1).trim()
|
||||
if (key && !(key in process.env)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
loadEnv()
|
||||
|
||||
const cli = cac('cook')
|
||||
|
||||
cli
|
||||
.command('convert', '将本地 CSV 数据转换为 JSON')
|
||||
.action(() => {
|
||||
runConvert()
|
||||
})
|
||||
|
||||
cli
|
||||
.command('fetch', '从飞书拉取菜谱数据并生成 CSV + JSON')
|
||||
.action(async () => {
|
||||
const { runFetch } = await import('./commands/fetch.js')
|
||||
await runFetch()
|
||||
})
|
||||
|
||||
cli.help()
|
||||
cli.version(version)
|
||||
cli.parse()
|
||||
export type { SearchOptions, SearchResult, SearchResultItem } from './utils/search'
|
||||
|
||||
38
packages/cook/src/utils/alias.ts
Normal file
38
packages/cook/src/utils/alias.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** 常见别名 → 数据库标准名 */
|
||||
const STUFF_ALIASES: Record<string, string> = {
|
||||
西红柿: '番茄',
|
||||
土豆丝: '土豆',
|
||||
洋芋: '土豆',
|
||||
马铃薯: '土豆',
|
||||
西兰花: '花菜',
|
||||
蘑菇: '菌菇',
|
||||
香菇: '菌菇',
|
||||
金针菇: '菌菇',
|
||||
口蘑: '菌菇',
|
||||
鸡翅: '鸡肉',
|
||||
鸡腿: '鸡肉',
|
||||
鸡胸肉: '鸡肉',
|
||||
排骨: '猪肉',
|
||||
五花肉: '猪肉',
|
||||
里脊: '猪肉',
|
||||
肥牛: '牛肉',
|
||||
牛腩: '牛肉',
|
||||
虾仁: '虾',
|
||||
大白菜: '白菜',
|
||||
卷心菜: '包菜',
|
||||
泡面: '方便面',
|
||||
挂面: '面食',
|
||||
面条: '面食',
|
||||
米饭: '米',
|
||||
大米: '米',
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户输入的食材标准化为数据库中的名称
|
||||
*/
|
||||
export function normalizeStuff(input: string[]): string[] {
|
||||
return input.map((s) => {
|
||||
const trimmed = s.trim()
|
||||
return STUFF_ALIASES[trimmed] ?? trimmed
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RecipeItem } from '../../types.js'
|
||||
import type { RecipeItem } from '../types/index.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RECIPE_CSV_HEADERS } from './config.js'
|
||||
import { cleanBv, parseRecipeCsv, recipesToCsv } from './csv.js'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IncompatibleRule, RecipeItem } from '../../types.js'
|
||||
import type { IncompatibleRule, RecipeItem } from '../types/index.js'
|
||||
import consola from 'consola'
|
||||
import Papa from 'papaparse'
|
||||
import {
|
||||
|
||||
264
packages/cook/src/utils/search.test.ts
Normal file
264
packages/cook/src/utils/search.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { IncompatibleRule, RecipeItem } from '../types/index.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeStuff } from './alias.js'
|
||||
import { searchRecipes } from './search.js'
|
||||
|
||||
// ─── Test Fixtures ───────────────────────────────────
|
||||
|
||||
const recipes: RecipeItem[] = [
|
||||
{
|
||||
name: '番茄炒蛋',
|
||||
stuff: ['番茄', '鸡蛋'],
|
||||
bv: 'BV1rf4y1872R',
|
||||
difficulty: '普通',
|
||||
tags: ['家常菜'],
|
||||
methods: ['炒'],
|
||||
tools: ['一口大锅'],
|
||||
},
|
||||
{
|
||||
name: '番茄鸡蛋面',
|
||||
stuff: ['方便面', '番茄', '鸡蛋'],
|
||||
bv: 'BV1tL4y1b7SM',
|
||||
difficulty: '简单',
|
||||
tags: [],
|
||||
methods: ['煮'],
|
||||
tools: ['一口大锅'],
|
||||
},
|
||||
{
|
||||
name: '电饭煲版番茄牛腩焖饭',
|
||||
stuff: ['牛肉', '番茄', '米'],
|
||||
bv: 'BV1Bv411C7X3',
|
||||
difficulty: '简单',
|
||||
tags: ['懒人'],
|
||||
methods: [],
|
||||
tools: ['电饭煲'],
|
||||
},
|
||||
{
|
||||
name: '酸辣土豆丝',
|
||||
stuff: ['土豆'],
|
||||
difficulty: '普通',
|
||||
tags: ['家常菜'],
|
||||
methods: ['炒'],
|
||||
tools: ['一口大锅'],
|
||||
},
|
||||
{
|
||||
name: '空气炸锅烤鸡腿',
|
||||
stuff: ['鸡肉'],
|
||||
bv: 'BV1Zr4y1B7UQ',
|
||||
difficulty: '普通',
|
||||
tags: [],
|
||||
methods: ['炸'],
|
||||
tools: ['空气炸锅'],
|
||||
},
|
||||
{
|
||||
name: '微波炉版番茄鸡蛋汤',
|
||||
stuff: ['番茄', '鸡蛋'],
|
||||
bv: 'BV1qx411n7QF',
|
||||
tags: [],
|
||||
methods: [],
|
||||
tools: ['微波炉'],
|
||||
},
|
||||
]
|
||||
|
||||
const incompatible: IncompatibleRule[] = [
|
||||
{ foodA: '牛奶', foodB: '韭菜', reason: '牛奶与韭菜同食会影响钙的吸收' },
|
||||
]
|
||||
|
||||
// ─── normalizeStuff ──────────────────────────────────
|
||||
|
||||
describe('normalizeStuff', () => {
|
||||
it('should normalize known aliases', () => {
|
||||
expect(normalizeStuff(['西红柿'])).toEqual(['番茄'])
|
||||
expect(normalizeStuff(['泡面'])).toEqual(['方便面'])
|
||||
expect(normalizeStuff(['鸡翅'])).toEqual(['鸡肉'])
|
||||
expect(normalizeStuff(['排骨'])).toEqual(['猪肉'])
|
||||
expect(normalizeStuff(['肥牛'])).toEqual(['牛肉'])
|
||||
expect(normalizeStuff(['虾仁'])).toEqual(['虾'])
|
||||
})
|
||||
|
||||
it('should keep unknown names unchanged', () => {
|
||||
expect(normalizeStuff(['番茄'])).toEqual(['番茄'])
|
||||
expect(normalizeStuff(['鸡蛋'])).toEqual(['鸡蛋'])
|
||||
expect(normalizeStuff(['鹅肝'])).toEqual(['鹅肝'])
|
||||
})
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
expect(normalizeStuff([' 西红柿 '])).toEqual(['番茄'])
|
||||
expect(normalizeStuff([' 鸡蛋 '])).toEqual(['鸡蛋'])
|
||||
})
|
||||
|
||||
it('should handle mixed aliases and regular names', () => {
|
||||
expect(normalizeStuff(['西红柿', '鸡蛋', '泡面'])).toEqual(['番茄', '鸡蛋', '方便面'])
|
||||
})
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(normalizeStuff([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── searchRecipes ───────────────────────────────────
|
||||
|
||||
describe('searchRecipes', () => {
|
||||
describe('stuff filtering', () => {
|
||||
it('should match by single stuff', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄'] })
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r => r.stuff.includes('番茄'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should match by multiple stuff', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄', '鸡蛋'] })
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r =>
|
||||
r.stuff.includes('番茄') || r.stuff.includes('鸡蛋'),
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('should normalize aliases before matching', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['西红柿'] })
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r => r.stuff.includes('番茄'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty for unmatched stuff', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['鱼'] })
|
||||
expect(result.recipes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return all recipes when no stuff specified', () => {
|
||||
const result = searchRecipes(recipes, incompatible, {})
|
||||
expect(result.recipes).toHaveLength(recipes.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool filtering', () => {
|
||||
it('should filter by tool', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { tool: '电饭煲' })
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r => r.tools.some(t => t.includes('电饭煲')))).toBe(true)
|
||||
})
|
||||
|
||||
it('should filter by tool with stuff', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄'], tool: '微波炉' })
|
||||
expect(result.recipes).toHaveLength(1)
|
||||
expect(result.recipes[0].name).toBe('微波炉版番茄鸡蛋汤')
|
||||
})
|
||||
})
|
||||
|
||||
describe('difficulty filtering', () => {
|
||||
it('should filter by difficulty', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { difficulty: '简单' })
|
||||
expect(result.recipes.every(r => r.difficulty === '简单')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tag filtering', () => {
|
||||
it('should filter by tag', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { tag: '懒人' })
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r => r.tags?.some(t => t.includes('懒人')))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('method filtering', () => {
|
||||
it('should filter by method', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { method: '炒' })
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r => r.methods?.some(m => m.includes('炒')))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting', () => {
|
||||
it('should sort by matched stuff count descending', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄', '鸡蛋'] })
|
||||
// 完全匹配(2/2 食材)的应排在前面
|
||||
const fullMatch = result.recipes.filter(r => r.matchedStuff.length === 2)
|
||||
const partialMatch = result.recipes.filter(r => r.matchedStuff.length === 1)
|
||||
if (fullMatch.length > 0 && partialMatch.length > 0) {
|
||||
const firstPartialIdx = result.recipes.indexOf(partialMatch[0])
|
||||
const lastFullIdx = result.recipes.indexOf(fullMatch.at(-1))
|
||||
expect(lastFullIdx).toBeLessThan(firstPartialIdx)
|
||||
}
|
||||
})
|
||||
|
||||
it('should prefer fewer total stuff when matched count is same', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄', '鸡蛋'] })
|
||||
// 番茄炒蛋(2食材) 和 微波炉版番茄鸡蛋汤(2食材) 应排在 番茄鸡蛋面(3食材) 前面
|
||||
const twoStuff = result.recipes.filter(r => r.stuff.length === 2 && r.matchedStuff.length === 2)
|
||||
const threeStuff = result.recipes.filter(r => r.stuff.length === 3 && r.matchedStuff.length === 2)
|
||||
if (twoStuff.length > 0 && threeStuff.length > 0) {
|
||||
const firstThreeIdx = result.recipes.indexOf(threeStuff[0])
|
||||
const lastTwoIdx = result.recipes.indexOf(twoStuff.at(-1))
|
||||
expect(lastTwoIdx).toBeLessThan(firstThreeIdx)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('limit', () => {
|
||||
it('should respect limit', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { limit: 2 })
|
||||
expect(result.recipes.length).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should default to 10', () => {
|
||||
const result = searchRecipes(recipes, incompatible, {})
|
||||
expect(result.recipes.length).toBeLessThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('result fields', () => {
|
||||
it('should include matchedStuff and missingStuff', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄'] })
|
||||
const r = result.recipes.find(r => r.name === '番茄炒蛋')!
|
||||
expect(r.matchedStuff).toEqual(['番茄'])
|
||||
expect(r.missingStuff).toEqual(['鸡蛋'])
|
||||
})
|
||||
|
||||
it('should generate videoUrl from bv', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄', '鸡蛋'] })
|
||||
const r = result.recipes.find(r => r.name === '番茄炒蛋')!
|
||||
expect(r.videoUrl).toBe('https://www.bilibili.com/video/BV1rf4y1872R')
|
||||
})
|
||||
|
||||
it('should have no videoUrl when bv is missing', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['土豆'] })
|
||||
const r = result.recipes.find(r => r.name === '酸辣土豆丝')!
|
||||
expect(r.videoUrl).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('incompatible warnings', () => {
|
||||
it('should warn about incompatible foods', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['牛奶', '韭菜'] })
|
||||
expect(result.warnings).toHaveLength(1)
|
||||
expect(result.warnings[0].foodA).toBe('牛奶')
|
||||
expect(result.warnings[0].foodB).toBe('韭菜')
|
||||
})
|
||||
|
||||
it('should not warn for compatible foods', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['番茄', '鸡蛋'] })
|
||||
expect(result.warnings).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not warn with single food', () => {
|
||||
const result = searchRecipes(recipes, incompatible, { stuff: ['牛奶'] })
|
||||
expect(result.warnings).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('combined filters', () => {
|
||||
it('should combine stuff + tool + difficulty', () => {
|
||||
const result = searchRecipes(recipes, incompatible, {
|
||||
stuff: ['番茄'],
|
||||
tool: '一口大锅',
|
||||
difficulty: '普通',
|
||||
})
|
||||
expect(result.recipes.length).toBeGreaterThan(0)
|
||||
expect(result.recipes.every(r =>
|
||||
r.stuff.includes('番茄')
|
||||
&& r.tools.some(t => t.includes('一口大锅'))
|
||||
&& r.difficulty === '普通',
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
96
packages/cook/src/utils/search.ts
Normal file
96
packages/cook/src/utils/search.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { IncompatibleRule, RecipeItem } from '../types/index.js'
|
||||
import { normalizeStuff } from './alias.js'
|
||||
|
||||
export interface SearchOptions {
|
||||
/** 食材列表 */
|
||||
stuff?: string[]
|
||||
/** 厨具 */
|
||||
tool?: string
|
||||
/** 难度 */
|
||||
difficulty?: string
|
||||
/** 标签 */
|
||||
tag?: string
|
||||
/** 烹饪方式 */
|
||||
method?: string
|
||||
/** 最大返回数 */
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface SearchResultItem extends RecipeItem {
|
||||
/** 匹配的食材 */
|
||||
matchedStuff: string[]
|
||||
/** 缺少的食材 */
|
||||
missingStuff: string[]
|
||||
/** 视频链接 */
|
||||
videoUrl?: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
recipes: SearchResultItem[]
|
||||
warnings: IncompatibleRule[]
|
||||
}
|
||||
|
||||
export function searchRecipes(
|
||||
recipes: RecipeItem[],
|
||||
incompatible: IncompatibleRule[],
|
||||
options: SearchOptions,
|
||||
): SearchResult {
|
||||
const { stuff = [], tool, difficulty, tag, method, limit = 10 } = options
|
||||
|
||||
const normalized = normalizeStuff(stuff)
|
||||
const stuffLower = normalized.map(s => s.toLowerCase())
|
||||
|
||||
let matched = recipes.filter((r) => {
|
||||
if (stuffLower.length > 0) {
|
||||
const has = r.stuff.some(s => stuffLower.includes(s.toLowerCase()))
|
||||
if (!has)
|
||||
return false
|
||||
}
|
||||
if (tool && !r.tools.some(t => t.includes(tool)))
|
||||
return false
|
||||
if (difficulty && r.difficulty !== difficulty)
|
||||
return false
|
||||
if (tag && !r.tags?.some(t => t.includes(tag)))
|
||||
return false
|
||||
if (method && !r.methods?.some(m => m.includes(method)))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 按匹配食材数排序(多的优先),相同时食材更少的优先
|
||||
if (stuffLower.length > 0) {
|
||||
matched.sort((a, b) => {
|
||||
const aCount = a.stuff.filter(s => stuffLower.includes(s.toLowerCase())).length
|
||||
const bCount = b.stuff.filter(s => stuffLower.includes(s.toLowerCase())).length
|
||||
if (bCount === aCount)
|
||||
return a.stuff.length - b.stuff.length
|
||||
return bCount - aCount
|
||||
})
|
||||
}
|
||||
|
||||
matched = matched.slice(0, limit)
|
||||
|
||||
const resultRecipes: SearchResultItem[] = matched.map((r) => {
|
||||
const matchedStuff = r.stuff.filter(s => stuffLower.includes(s.toLowerCase()))
|
||||
const missingStuff = r.stuff.filter(s => !stuffLower.includes(s.toLowerCase()))
|
||||
return {
|
||||
...r,
|
||||
matchedStuff,
|
||||
missingStuff,
|
||||
videoUrl: r.bv ? `https://www.bilibili.com/video/${r.bv}` : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// 检查食物相克
|
||||
const warnings: IncompatibleRule[] = []
|
||||
if (stuffLower.length >= 2) {
|
||||
for (const rule of incompatible) {
|
||||
const hasA = stuffLower.includes(rule.foodA.toLowerCase())
|
||||
const hasB = stuffLower.includes(rule.foodB.toLowerCase())
|
||||
if (hasA && hasB)
|
||||
warnings.push(rule)
|
||||
}
|
||||
}
|
||||
|
||||
return { recipes: resultRecipes, warnings }
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"paths": {
|
||||
"~/*": ["../../app/*"]
|
||||
},
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export type { Cookbook } from '@cook/types/cookbook'
|
||||
export type { IncompatibleRule } from '@cook/types/incompatible'
|
||||
export type { Difficulty, RecipeItem, Recipes, StuffItem } from '@cook/types/recipe'
|
||||
@@ -1,19 +0,0 @@
|
||||
# @cook/types
|
||||
|
||||
共享类型定义包,用于在项目各模块间共享 TypeScript 类型。
|
||||
|
||||
## 导出的类型
|
||||
|
||||
- `Cookbook`: 菜谱集合
|
||||
- `RecipeItem`, `Recipes`: 菜谱和菜谱列表
|
||||
- `StuffItem`: 食材
|
||||
- `IncompatibleRule`: 食物相克规则
|
||||
- `Difficulty`: 难度类型
|
||||
|
||||
## 使用
|
||||
|
||||
```ts
|
||||
import type { Cookbook, RecipeItem } from '@cook/types'
|
||||
// 或者单独导入
|
||||
import type { Cookbook } from '@cook/types/cookbook'
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@cook/types",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./cookbook": "./src/cookbook.ts",
|
||||
"./recipe": "./src/recipe.ts",
|
||||
"./incompatible": "./src/incompatible-foods.ts"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3246
pnpm-lock.yaml
generated
3246
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
64
skills/cook/README.md
Normal file
64
skills/cook/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 🍳 Cook Skill — 食用手册 AI 美食助手
|
||||
|
||||
> 围绕「今天吃什么」的生活灵感助手,内置数百道中文家常菜谱数据库。
|
||||
|
||||
## 功能
|
||||
|
||||
- **食材找菜谱** —— 告诉 AI 你有什么食材,推荐能做的菜(附 B站视频教程)
|
||||
- **饮食规划** —— 一周菜单安排,荤素搭配,买一次菜用一周
|
||||
- **食材知识** —— 食物相克查询、保存方法、食材替代方案
|
||||
- **烹饪技巧** —— 新手友好的调味比例、翻车避坑指南
|
||||
|
||||
## 数据源
|
||||
|
||||
菜谱数据来自 [Cook(食用手册)](https://github.com/YunYouJun/cook) 项目,包含:
|
||||
|
||||
- 数百道家常菜谱(含食材、难度、厨具、做法、B站视频链接)
|
||||
- 食物相克数据
|
||||
- 食材/厨具分类
|
||||
|
||||
数据通过 GitHub Raw URL 在线获取,无需本地项目。
|
||||
|
||||
## 安装
|
||||
|
||||
将 `cook/` 文件夹(包含 `SKILL.md`)放入你的 Skills 目录:
|
||||
|
||||
### CodeBuddy IDE
|
||||
|
||||
**方式一:手动复制**
|
||||
|
||||
```bash
|
||||
# 项目级(仅当前项目生效)
|
||||
cp -r cook/ <your-project>/.codebuddy/skills/cook/
|
||||
|
||||
# 用户级(所有项目生效)
|
||||
cp -r cook/ ~/.codebuddy/skills/cook/
|
||||
```
|
||||
|
||||
**方式二:IDE 导入**
|
||||
|
||||
设置页 → Skills → 点击「导入 Skill」 → 选择 `cook/` 文件夹
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
# 项目级
|
||||
cp -r cook/ <your-project>/.claude/skills/cook/
|
||||
|
||||
# 用户级
|
||||
cp -r cook/ ~/.claude/skills/cook/
|
||||
```
|
||||
|
||||
安装后可通过 `/cook` 斜杠命令手动调用,或由 AI 根据对话内容自动触发。
|
||||
|
||||
## 兼容性
|
||||
|
||||
| 工具 | Skills 路径 | 状态 |
|
||||
| -------------------- | -------------------- | ---- |
|
||||
| CodeBuddy IDE | `.codebuddy/skills/` | ✅ |
|
||||
| CodeBuddy Code (CLI) | `.codebuddy/skills/` | ✅ |
|
||||
| Claude Code | `.claude/skills/` | ✅ |
|
||||
|
||||
## 许可
|
||||
|
||||
[MIT](https://github.com/YunYouJun/cook/blob/main/LICENSE)
|
||||
182
skills/cook/SKILL.md
Normal file
182
skills/cook/SKILL.md
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: cook
|
||||
description: "食用手册 —— 围绕「吃什么」的生活灵感助手。帮助用户根据手边食材想菜谱、做每周饮食规划、了解食材搭配与储存知识、获取烹饪灵感。当用户提到做饭、做菜、食材搭配、菜谱推荐、饮食规划、今天吃什么、冰箱里有什么能做、食物相克、烹饪技巧、节气饮食、减脂餐、宝宝辅食等与日常饮食生活相关的话题时使用此 skill。"
|
||||
metadata:
|
||||
author: YunYouJun
|
||||
version: 0.3.0
|
||||
repository: https://github.com/YunYouJun/cook
|
||||
---
|
||||
|
||||
# 🍳 食用手册 —— 你的饮食生活灵感助手
|
||||
|
||||
你是一位温暖、实用、有生活智慧的中文美食助手。你的使命是帮助每一个普通人解决「今天吃什么」这个永恒难题,让做饭变成一件轻松愉快的事。
|
||||
|
||||
## 你是谁
|
||||
|
||||
- 一个懂家常菜、会过日子的美食朋友
|
||||
- 说话亲切自然,像朋友聊天,不说教
|
||||
- 推荐的菜要真的能做出来,不搞花架子
|
||||
- 尊重用户的实际条件:食材有限、厨具简单、时间紧张都很正常
|
||||
|
||||
---
|
||||
|
||||
## 📦 菜谱数据库
|
||||
|
||||
这是你最强大的能力——你可以检索真实的菜谱数据库,给出**有据可查**的推荐。
|
||||
|
||||
### 数据源
|
||||
|
||||
| 数据 | 在线地址(GitHub Raw) | 格式 |
|
||||
| ------------- | --------------------------------------------------------------------------------------- | ---------- |
|
||||
| 菜谱库 | `https://raw.githubusercontent.com/YunYouJun/cook/main/app/data/recipe.csv` | CSV |
|
||||
| 食物相克 | `https://raw.githubusercontent.com/YunYouJun/cook/main/app/data/incompatible-foods.csv` | CSV |
|
||||
| 食材/厨具分类 | `https://raw.githubusercontent.com/YunYouJun/cook/main/app/data/food.ts` | TypeScript |
|
||||
|
||||
> **本地优先**:如果在 Cook 项目本地使用,直接读取 `app/data/` 目录下的文件,无需网络请求。
|
||||
> **独立使用**:通过上述 URL 在线获取数据即可,不依赖本地项目。
|
||||
|
||||
### CSV 字段说明
|
||||
|
||||
**recipe.csv**
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
| ------------ | ------------------------ | ---------------------------------------- |
|
||||
| `name` | 菜名 | 电饭煲版广式腊肠煲饭 |
|
||||
| `stuff` | 所需食材(中文顿号分隔) | 腊肠、米 |
|
||||
| `bv` | B站视频 BV 号 | BV1NE411Q7Jj |
|
||||
| `difficulty` | 难度:简单 / 普通 / 困难 | 简单 |
|
||||
| `tags` | 标签 | 懒人、下饭、广式 |
|
||||
| `methods` | 烹饪方式 | 煲、炒、煮、蒸、烤、炸 |
|
||||
| `tools` | 所需厨具 | 电饭煲、烤箱、空气炸锅、微波炉、一口大锅 |
|
||||
|
||||
> 视频教程链接格式:`https://www.bilibili.com/video/{bv}`
|
||||
|
||||
**incompatible-foods.csv**
|
||||
|
||||
| 字段 | 说明 |
|
||||
| -------- | -------------- |
|
||||
| `foodA` | 食物 A |
|
||||
| `foodB` | 食物 B |
|
||||
| `reason` | 不宜同食的原因 |
|
||||
|
||||
### 检索流程
|
||||
|
||||
当用户询问菜谱相关问题时,**必须**使用 CLI 检索,**不要直接读取 CSV 文件**(避免浪费 token)。
|
||||
|
||||
#### 方式一:在 Cook 项目内使用(推荐)
|
||||
|
||||
```bash
|
||||
pnpm --filter @yunyoujun/cook search --stuff "鸡蛋,番茄" --json [--tool "电饭煲"] [--difficulty "简单"] [--tag "懒人"] [--method "炒"] [--limit 10]
|
||||
```
|
||||
|
||||
#### 方式二:通过 npx 使用(已发布后)
|
||||
|
||||
```bash
|
||||
npx @yunyoujun/cook search --stuff "鸡蛋,番茄" --json
|
||||
```
|
||||
|
||||
#### 方式三:Fallback(CLI 不可用时)
|
||||
|
||||
直接读取 `app/data/recipe.csv` 或通过 GitHub Raw URL 获取,手动筛选。
|
||||
|
||||
CLI 返回精简 JSON,包含匹配菜谱(含视频链接、匹配/缺少的食材)和食物相克警告,直接基于结果推荐。支持食材别名(如 西红柿→番茄、泡面→方便面)。
|
||||
|
||||
#### 检索参数说明
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
| -------------- | -------------- | ------------- |
|
||||
| `--stuff` | 食材,逗号分隔 | `"鸡蛋,番茄"` |
|
||||
| `--tool` | 厨具 | `"电饭煲"` |
|
||||
| `--difficulty` | 难度 | `"简单"` |
|
||||
| `--tag` | 标签 | `"懒人"` |
|
||||
| `--method` | 做法 | `"炒"` |
|
||||
| `--limit` | 最大返回数 | `10` |
|
||||
|
||||
### 推荐格式
|
||||
|
||||
```
|
||||
在菜谱库里帮你找到了这些!
|
||||
|
||||
🍅 **电饭煲版一只番茄饭** —— 简单 · 杂烩
|
||||
食材:土豆、胡萝卜、香肠、番茄、鸡蛋、米
|
||||
你有其中 2 样(土豆、鸡蛋),再买番茄和香肠就能做
|
||||
📺 视频教程:https://www.bilibili.com/video/BV1dj411f7sR
|
||||
|
||||
🥔 **电饭煲版土豆排骨焖饭** —— 简单 · 懒人
|
||||
食材:猪肉、土豆、米、腊肠
|
||||
📺 视频教程:https://www.bilibili.com/video/BV1Bv411C7X3
|
||||
|
||||
💡 数据库里没有完全匹配的菜?没关系,这里再推荐一道经典家常:
|
||||
🥚 **土豆丝炒蛋** —— 大锅快炒,10 分钟搞定
|
||||
```
|
||||
|
||||
> **原则**:优先推荐数据库中的真实菜谱(附视频教程链接),数据库无法满足时再补充通用建议。
|
||||
> 推荐时标注匹配程度(「你有其中 X 样」「还需要 XX」),帮用户判断可行性。
|
||||
|
||||
---
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 🥘 食材找菜谱
|
||||
|
||||
用户告诉你手边有什么食材,你来推荐能做的菜。
|
||||
|
||||
**思考方式:**
|
||||
|
||||
1. 先检索数据库,找到匹配的真实菜谱
|
||||
2. 考虑用户的厨具条件
|
||||
3. 从结果中挑选 2-3 道最合适的菜,附上视频链接
|
||||
4. 数据库匹配不足时,补充 1-2 道通用家常菜建议
|
||||
5. 食材不够时,指出「再买一个 XX 就能做 YY」
|
||||
|
||||
### 📅 饮食规划
|
||||
|
||||
帮用户做简单实用的饮食安排,不追求完美营养学,追求**能坚持**。
|
||||
|
||||
**原则:**
|
||||
|
||||
- 一周菜单不重样,但食材可以复用(买一次菜用一周)
|
||||
- 荤素搭配,主食轮换
|
||||
- 兼顾口味变化:今天重口味明天可以清淡点
|
||||
- 考虑实际:工作日要快,周末可以花点时间
|
||||
|
||||
### 🧠 食材知识
|
||||
|
||||
- **食物相克**:先检索 incompatible-foods.csv,理性说明影响程度,不过度恐慌。格式:⚠️ XX 和 YY 不建议一起吃,因为……
|
||||
- **食材保存**:什么该放冰箱、保鲜时间、剩菜处理
|
||||
- **食材替代**:没有 XX 可以用 YY 代替,说明口味差异
|
||||
|
||||
### 🍜 烹饪技巧
|
||||
|
||||
分享实用技巧,让新手也能做出好味道:肉怎么腌更嫩、蔬菜怎么炒不出水、调味基本比例、常见翻车避坑。
|
||||
|
||||
---
|
||||
|
||||
## 适用场景
|
||||
|
||||
| 场景 | 关注点 |
|
||||
| -------------- | -------------------------------- |
|
||||
| 冰箱清理日 | 不浪费,用有限食材做出好吃的 |
|
||||
| 新手第一次做饭 | 失败率低、步骤少、不需复杂调料 |
|
||||
| 给家人朋友做饭 | 有面子但不难做 |
|
||||
| 减脂/健康饮食 | 低油低盐、高蛋白,附热量参考 |
|
||||
| 一人食 | 份量小、不浪费、做起来快 |
|
||||
| 带便当 | 适合加热、不易变味、方便携带 |
|
||||
| 宝宝辅食 | 新鲜安全、口味清淡、注意过敏风险 |
|
||||
|
||||
---
|
||||
|
||||
## 回答风格
|
||||
|
||||
- **语言**:中文,口语化,像朋友聊天
|
||||
- **emoji**:适当使用食物 emoji 增加趣味,不过度
|
||||
- **结构**:菜名加粗,简短说明做法或亮点,不写完整食谱除非用户要求
|
||||
- **务实**:承认「这个食材组合确实有点难搞」也是一种诚实
|
||||
- **鼓励**:做饭是一件有趣的事,即使翻车了也没关系
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要编造不存在的菜谱,推荐的菜要确实合理可做
|
||||
- 食物相克信息要有依据,不传播未经证实的谣言
|
||||
- 涉及婴幼儿、孕妇、过敏体质等特殊人群时,提醒用户咨询专业意见
|
||||
- 不替代医疗或营养师的专业建议
|
||||
Reference in New Issue
Block a user