1
0
mirror of synced 2026-05-20 09:16:31 +08:00

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:
YunYouJun
2026-04-12 19:22:54 +08:00
parent 7b4c1a6029
commit 7028efeb5e
34 changed files with 3016 additions and 1352 deletions

1
.codebuddy/skills/cook Symbolic link
View File

@@ -0,0 +1 @@
../../skills/cook

36
.github/workflows/publish-cook.yml vendored Normal file
View 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 }}

View File

@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.0"
setups.@nuxt/test-utils="4.0.2"

View File

@@ -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号'

View File

@@ -1 +1 @@
export type { Cookbook } from '@cook/types/cookbook'
export type { Cookbook } from '@yunyoujun/cook'

View File

@@ -1 +1 @@
export type { IncompatibleRule } from '@cook/types/incompatible'
export type { IncompatibleRule } from '@yunyoujun/cook'

View File

@@ -1 +1 @@
export type { Difficulty, RecipeItem, Recipes, StuffItem } from '@cook/types/recipe'
export type { Difficulty, RecipeItem, Recipes, StuffItem } from '@yunyoujun/cook'

View File

@@ -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",

View File

@@ -102,7 +102,7 @@ export default defineNuxtConfig({
},
alias: {
'@cook/types': './packages/types/src',
'@cook/types': './packages/cook/src/types',
},
future: {

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,13 @@
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: [
'src/index',
'src/cli',
],
clean: true,
declaration: true,
rollup: {
emitCJS: false,
},
})

View File

@@ -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
View 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()

View File

@@ -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 {

View 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}`)
}
}
}

View File

@@ -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'

View 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
})
}

View File

@@ -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'

View File

@@ -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 {

View 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)
})
})
})

View 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 }
}

View File

@@ -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,

View File

@@ -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'

View File

@@ -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'
```

View File

@@ -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"
]
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

64
skills/cook/README.md Normal file
View 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
View 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
```
#### 方式三FallbackCLI 不可用时)
直接读取 `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 增加趣味,不过度
- **结构**:菜名加粗,简短说明做法或亮点,不写完整食谱除非用户要求
- **务实**:承认「这个食材组合确实有点难搞」也是一种诚实
- **鼓励**:做饭是一件有趣的事,即使翻车了也没关系
## 注意事项
- 不要编造不存在的菜谱,推荐的菜要确实合理可做
- 食物相克信息要有依据,不传播未经证实的谣言
- 涉及婴幼儿、孕妇、过敏体质等特殊人群时,提醒用户咨询专业意见
- 不替代医疗或营养师的专业建议