Skip to content

Commit

Permalink
Merge pull request #20 from mst-mkt/feat/get-recipes-from-web_#6
Browse files Browse the repository at this point in the history
Feat/get recipes from web #6
  • Loading branch information
mst-mkt authored Jun 21, 2024
2 parents 63a10f4 + 68127be commit 68d9fee
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 38 deletions.
6 changes: 4 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "backend",
"scripts": {
"dev": "wrangler dev src/index.ts",
"dev": "wrangler dev src/index.ts --port 8787",
"deploy": "wrangler deploy --minify src/index.ts",
"check": "biome check ./src/",
"fix": "biome check --write ./src/",
Expand All @@ -11,6 +11,7 @@
"@hono/zod-validator": "^0.2.2",
"@langchain/core": "^0.2.6",
"@langchain/openai": "^0.1.3",
"cheerio": "1.0.0-rc.12",
"hono": "^4.4.5",
"langchain": "^0.2.5",
"zod": "^3.23.8"
Expand All @@ -21,5 +22,6 @@
"biome-config": "workspace:*",
"typescript": "^5.4.5",
"wrangler": "^3.57.2"
}
},
"type": "module"
}
43 changes: 43 additions & 0 deletions apps/backend/src/getRecipes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { load } from 'cheerio'
import type { ingredients } from './schemas/ingredientsSchema'
import { type Recipes, recipeSchema } from './schemas/recipesSchema'
import { createSearchRecipesUrl } from './utils/createSearchUrl'

const fetchRecipes = async (url: string): Promise<Recipes> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`Failed to fetch the recipes: ${res.statusText}`)
const data = await res.text()

const $ = load(data)
const recipes = $('.recipe-list .recipe-preview')
.map((index, element) => {
const recipeImage = $(element).find('.recipe-image img').attr('src')
const recipeTitle = $(element).find('.recipe-title').text().trim()
const ingredients = $(element)
.find('.ingredients')
.text()
.trim()
.split('、')
.map((ingredient) => ingredient.trim())
.filter((ingredient) => !ingredient.includes('\n...'))
const newRecipe = { recipeImage, recipeTitle, ingredients }

const validatedRecipe = recipeSchema.safeParse(newRecipe)
if (!validatedRecipe.success) {
console.error(`Invalid recipe at index ${index}:`, validatedRecipe.error)
return null
}
return validatedRecipe.data
})
.get()
.filter((recipe) => recipe !== null)

return recipes
}

const getRecipesByIngredients = async (ingredients: ingredients): Promise<Recipes> => {
const searchUrl = createSearchRecipesUrl(ingredients)
return await fetchRecipes(searchUrl)
}

export { getRecipesByIngredients }
12 changes: 5 additions & 7 deletions apps/backend/src/imageToFoods.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { HumanMessage } from '@langchain/core/messages'
import { ChatOpenAI } from '@langchain/openai'
import { z } from 'zod'
import type { BindingsType } from './factory'
import { ingredientsSchema } from './schemas/ingredientsSchema'
import { fileToBase64 } from './utils/fileToBase64'

export const imageToFoods = async (file: File, envs: BindingsType) => {
Expand All @@ -24,12 +24,10 @@ export const imageToFoods = async (file: File, envs: BindingsType) => {

const imageUrl = await fileToBase64(file)

const foodSchema = z.object({
ingredients: z.array(z.string()).describe('画像に含まれている食材のリスト'),
const structuredLlm = model.withStructuredOutput(ingredientsSchema, {
name: 'food_detection',
})

const structuredLlm = model.withStructuredOutput(foodSchema, { name: 'food_detection' })

const message = new HumanMessage({
content: [
{
Expand All @@ -44,7 +42,7 @@ export const imageToFoods = async (file: File, envs: BindingsType) => {
})

const res = await structuredLlm.invoke([message])
const foodListData = res.ingredients
const foodsData = res.ingredients

return foodListData
return foodsData
}
13 changes: 9 additions & 4 deletions apps/backend/src/routers/recipes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { zValidator } from '@hono/zod-validator'
import { honoFactory } from '../factory'
import { recipesReqSchema } from '../schemas/recipesSchema'
import { getRecipesByIngredients } from '../getRecipes'
import { ingredientsSchema } from '../schemas/ingredientsSchema'

const recipesRouter = honoFactory
.createApp()
.get('/', zValidator('query', recipesReqSchema), async (c) => {
.get('/', zValidator('query', ingredientsSchema), async (c) => {
const { ingredients } = c.req.valid('query')

return c.json({ message: `hello ${ingredients.join(', ')}` })
try {
const recipes = await getRecipesByIngredients({ ingredients })
return c.json(recipes)
} catch (error) {
return c.json({ error }, 500)
}
})

export { recipesRouter }
8 changes: 8 additions & 0 deletions apps/backend/src/schemas/ingredientsSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from 'zod'
const ingredientsSchema = z.object({
ingredients: z.array(z.string()).describe('食材のリスト'),
})

type ingredients = z.infer<typeof ingredientsSchema>

export { ingredientsSchema, type ingredients }
15 changes: 9 additions & 6 deletions apps/backend/src/schemas/recipesSchema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { z } from 'zod'
import { ingredientsSchema } from './ingredientsSchema'

const recipesReqSchema = z
.object({
ingredients: z.array(z.string()),
})
.strict()
const recipeSchema = ingredientsSchema.extend({
recipeImage: z.string().url().describe('レシピの画像のURL'),
recipeTitle: z.string().describe('レシピのタイトル'),
})
const recipesSchema = z.array(recipeSchema)

export { recipesReqSchema }
type Recipes = z.infer<typeof recipesSchema>

export { recipeSchema, recipesSchema, type Recipes }
11 changes: 11 additions & 0 deletions apps/backend/src/utils/createSearchUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ingredients } from '../schemas/ingredientsSchema'

const createSearchRecipesUrl = (ingredients: ingredients): string => {
const baseSearchUrl = 'https://cookpad.com/search/'
const searchParams = ingredients.ingredients
.map((ingredients) => encodeURIComponent(ingredients))
.join('%20')
return `${baseSearchUrl}${searchParams}`
}

export { createSearchRecipesUrl }
12 changes: 4 additions & 8 deletions apps/backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,9 @@
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": [
"@cloudflare/workers-types"
],
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
}
}
}
2 changes: 1 addition & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"scripts": {
"dev": "vite",
"dev": "vite --port 5173",
"build": "tsc && vite build",
"check": "biome check ./src/",
"fix": "biome check --write ./src/",
Expand Down
44 changes: 41 additions & 3 deletions apps/frontend/src/routes/index.lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@ const requestUrl = new URL(BACKEND_BASE_URL).origin.toString()

const honoRoutes = hc<HonoRoutes>(requestUrl)
const $imagePost = honoRoutes.upload.$post
const $getRecipes = honoRoutes.recipes.$get

const Home = () => {
const [file, setFile] = useState<File | null>(null)
const [foods, setFoods] = useState<string[]>([])
const [ingredients, setingredients] = useState<string[]>([])
const [recipes, setRecipes] = useState<
{
ingredients: string[]
recipeImage: string
recipeTitle: string
}[]
>([])

const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
Expand All @@ -27,14 +35,27 @@ const Home = () => {
}
}

const getRecipes = async () => {
const res = await $getRecipes({
query: { ingredients },
})
if (res.ok) {
const data = await res.json()
setRecipes(data)
} else {
const data = await res.json()
console.error(data)
}
}

const uploadFile = async () => {
if (!file) return
const res = await $imagePost({
form: { file },
})
if (res.ok) {
const data = await res.json()
setFoods(data.foods)
setingredients(data.foods)
} else {
const data = await res.json()
console.error(data.error)
Expand All @@ -48,10 +69,27 @@ const Home = () => {
Upload image
</button>
<ul>
{foods.map((food) => (
{ingredients.map((food) => (
<li key={food}>{food}</li>
))}
</ul>

<button type="button" onClick={getRecipes}>
Get recipes
</button>
<ul>
{recipes.map((recipe) => (
<li key={recipe.recipeTitle}>
<img src={recipe.recipeImage} alt={recipe.recipeTitle} />
<h2>{recipe.recipeTitle}</h2>
<ul>
{recipe.ingredients.map((ingredient) => (
<li key={ingredient}>{ingredient}</li>
))}
</ul>
</li>
))}
</ul>
</div>
)
}
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@
"turbo": "^2.0.4"
},
"lint-staged": {
"*.{js,ts,jsx,tsx,css}": [
"biome check --write"
]
"*.{js,ts,jsx,tsx,css}": ["biome check --write"]
},
"volta": {
"node": "20.14.0"
Expand Down
Loading

0 comments on commit 68d9fee

Please sign in to comment.