Compare commits
1 Commits
97969f8151
...
5482f60a16
| Author | SHA1 | Date | |
|---|---|---|---|
|
5482f60a16
|
@@ -55,14 +55,7 @@ interface RecipeJsonLd {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReferencedNutrition = {
|
export function generateRecipeJsonLd(data: RecipeModelType) {
|
||||||
shortName: string;
|
|
||||||
name: string;
|
|
||||||
nutrition: Record<string, number>;
|
|
||||||
baseMultiplier: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?: ReferencedNutrition[]) {
|
|
||||||
const jsonLd: RecipeJsonLd = {
|
const jsonLd: RecipeJsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Recipe",
|
"@type": "Recipe",
|
||||||
@@ -152,7 +145,7 @@ export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add nutrition information from stored mappings
|
// Add nutrition information from stored mappings
|
||||||
const nutritionInfo = computeNutritionInfo(data.ingredients || [], data.nutritionMappings, data.portions, referencedNutrition);
|
const nutritionInfo = computeNutritionInfo(data.ingredients || [], data.nutritionMappings, data.portions);
|
||||||
if (nutritionInfo) {
|
if (nutritionInfo) {
|
||||||
jsonLd.nutrition = nutritionInfo;
|
jsonLd.nutrition = nutritionInfo;
|
||||||
}
|
}
|
||||||
@@ -188,9 +181,8 @@ function computeNutritionInfo(
|
|||||||
ingredients: any[],
|
ingredients: any[],
|
||||||
mappings: NutritionMapping[] | undefined,
|
mappings: NutritionMapping[] | undefined,
|
||||||
portions: string | undefined,
|
portions: string | undefined,
|
||||||
referencedNutrition?: ReferencedNutrition[],
|
|
||||||
): Record<string, string> | null {
|
): Record<string, string> | null {
|
||||||
if ((!mappings || mappings.length === 0) && (!referencedNutrition || referencedNutrition.length === 0)) return null;
|
if (!mappings || mappings.length === 0) return null;
|
||||||
|
|
||||||
const index = new Map(
|
const index = new Map(
|
||||||
mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
|
mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
|
||||||
@@ -235,16 +227,6 @@ function computeNutritionInfo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add nutrition from referenced recipes (base refs + anchor-tag refs)
|
|
||||||
if (referencedNutrition) {
|
|
||||||
for (const ref of referencedNutrition) {
|
|
||||||
const scale = ref.baseMultiplier;
|
|
||||||
for (const key of Object.keys(totals) as (keyof typeof totals)[]) {
|
|
||||||
totals[key] += (ref.nutrition[key] || 0) * scale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totals.calories === 0) return null;
|
if (totals.calories === 0) return null;
|
||||||
|
|
||||||
// Parse portion count for per-serving values
|
// Parse portion count for per-serving values
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ async function getBlsEmbeddingIndex() {
|
|||||||
|
|
||||||
/** Normalize an ingredient name for matching (English) */
|
/** Normalize an ingredient name for matching (English) */
|
||||||
export function normalizeIngredientName(name: string): string {
|
export function normalizeIngredientName(name: string): string {
|
||||||
let normalized = name.replace(/<[^>]*>/g, '').toLowerCase().trim();
|
let normalized = name.toLowerCase().trim();
|
||||||
normalized = normalized.replace(/\(.*?\)/g, '').trim();
|
normalized = normalized.replace(/\(.*?\)/g, '').trim();
|
||||||
for (const mod of STRIP_MODIFIERS) {
|
for (const mod of STRIP_MODIFIERS) {
|
||||||
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
|
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
|
||||||
@@ -143,7 +143,7 @@ export function normalizeIngredientName(name: string): string {
|
|||||||
|
|
||||||
/** Normalize a German ingredient name for matching */
|
/** Normalize a German ingredient name for matching */
|
||||||
export function normalizeIngredientNameDe(name: string): string {
|
export function normalizeIngredientNameDe(name: string): string {
|
||||||
let normalized = name.replace(/<[^>]*>/g, '').toLowerCase().trim();
|
let normalized = name.toLowerCase().trim();
|
||||||
normalized = normalized.replace(/\(.*?\)/g, '').trim();
|
normalized = normalized.replace(/\(.*?\)/g, '').trim();
|
||||||
for (const mod of STRIP_MODIFIERS_DE) {
|
for (const mod of STRIP_MODIFIERS_DE) {
|
||||||
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
|
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
|
||||||
@@ -280,8 +280,7 @@ function substringMatchScore(
|
|||||||
const pos = nameLower.indexOf(form);
|
const pos = nameLower.indexOf(form);
|
||||||
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
|
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
|
||||||
// Word-boundary match
|
// Word-boundary match
|
||||||
const escaped = form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const wordBoundary = new RegExp(`(^|[\\s,/])${form}([\\s,/]|$)`);
|
||||||
const wordBoundary = new RegExp(`(^|[\\s,/])${escaped}([\\s,/]|$)`);
|
|
||||||
if (wordBoundary.test(nameLower)) hasWordBoundaryMatch = true;
|
if (wordBoundary.test(nameLower)) hasWordBoundaryMatch = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,72 +813,3 @@ export function computeRecipeNutritionTotals(
|
|||||||
function stripHtml(html: string): string {
|
function stripHtml(html: string): string {
|
||||||
return html.replace(/<[^>]*>/g, '');
|
return html.replace(/<[^>]*>/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse anchor href from ingredient name, return recipe short_name or null */
|
|
||||||
export function parseAnchorRecipeRef(ingredientName: string): string | null {
|
|
||||||
const match = ingredientName.match(/<a\s+href=["']?([^"' >]+)["']?[^>]*>/i);
|
|
||||||
if (!match) return null;
|
|
||||||
let href = match[1].trim();
|
|
||||||
href = href.split('?')[0];
|
|
||||||
if (href.startsWith('http') || href.includes('://')) return null;
|
|
||||||
href = href.replace(/^(\.?\/?rezepte\/|\.\/|\/)/, '');
|
|
||||||
if (href.includes('.')) return null;
|
|
||||||
return href || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ReferencedNutritionResult = {
|
|
||||||
shortName: string;
|
|
||||||
name: string;
|
|
||||||
nutrition: Record<string, number>;
|
|
||||||
baseMultiplier: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build nutrition totals for referenced recipes:
|
|
||||||
* 1. Base recipe references (type='reference' with populated baseRecipeRef)
|
|
||||||
* 2. Anchor-tag references in ingredient names (<a href=...>)
|
|
||||||
*/
|
|
||||||
export async function resolveReferencedNutrition(
|
|
||||||
ingredients: any[],
|
|
||||||
): Promise<ReferencedNutritionResult[]> {
|
|
||||||
const { Recipe } = await import('$models/Recipe');
|
|
||||||
const results: ReferencedNutritionResult[] = [];
|
|
||||||
const processedSlugs = new Set<string>();
|
|
||||||
|
|
||||||
for (const section of ingredients) {
|
|
||||||
// Type 1: Base recipe references
|
|
||||||
if (section.type === 'reference' && section.baseRecipeRef) {
|
|
||||||
const ref = section.baseRecipeRef;
|
|
||||||
const slug = ref.short_name;
|
|
||||||
if (processedSlugs.has(slug)) continue;
|
|
||||||
processedSlugs.add(slug);
|
|
||||||
|
|
||||||
if (ref.nutritionMappings?.length > 0) {
|
|
||||||
const mult = section.baseMultiplier || 1;
|
|
||||||
const nutrition = computeRecipeNutritionTotals(ref.ingredients || [], ref.nutritionMappings, 1);
|
|
||||||
results.push({ shortName: slug, name: ref.name, nutrition, baseMultiplier: mult });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type 2: Anchor-tag references in ingredient names
|
|
||||||
if (section.list) {
|
|
||||||
for (const item of section.list) {
|
|
||||||
const refSlug = parseAnchorRecipeRef(item.name || '');
|
|
||||||
if (!refSlug || processedSlugs.has(refSlug)) continue;
|
|
||||||
processedSlugs.add(refSlug);
|
|
||||||
|
|
||||||
const refRecipe = await Recipe.findOne({ short_name: refSlug })
|
|
||||||
.select('short_name name ingredients nutritionMappings portions')
|
|
||||||
.lean();
|
|
||||||
if (!refRecipe?.nutritionMappings?.length) continue;
|
|
||||||
|
|
||||||
const nutrition = computeRecipeNutritionTotals(
|
|
||||||
refRecipe.ingredients || [], refRecipe.nutritionMappings, 1
|
|
||||||
);
|
|
||||||
results.push({ shortName: refSlug, name: refRecipe.name, nutrition, baseMultiplier: 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types';
|
import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types';
|
||||||
import { isEnglish } from '$lib/server/recipeHelpers';
|
import { isEnglish } from '$lib/server/recipeHelpers';
|
||||||
import { getNutritionEntryByFdcId, getBlsEntryByCode, resolveReferencedNutrition } from '$lib/server/nutritionMatcher';
|
import { getNutritionEntryByFdcId, getBlsEntryByCode, computeRecipeNutritionTotals } from '$lib/server/nutritionMatcher';
|
||||||
|
|
||||||
/** Recursively map populated baseRecipeRef to resolvedRecipe field */
|
/** Recursively map populated baseRecipeRef to resolvedRecipe field */
|
||||||
function mapBaseRecipeRefs(items: any[]): any[] {
|
function mapBaseRecipeRefs(items: any[]): any[] {
|
||||||
@@ -42,6 +42,72 @@ function resolveNutritionData(mappings: any[]): any[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse anchor href from ingredient name, return short_name or null */
|
||||||
|
function parseAnchorRecipeRef(ingredientName: string): string | null {
|
||||||
|
const match = ingredientName.match(/<a\s+href=["']?([^"' >]+)["']?[^>]*>/i);
|
||||||
|
if (!match) return null;
|
||||||
|
let href = match[1].trim();
|
||||||
|
// Strip query params (e.g., ?multiplier={{multiplier}})
|
||||||
|
href = href.split('?')[0];
|
||||||
|
// Skip external links
|
||||||
|
if (href.startsWith('http') || href.includes('://')) return null;
|
||||||
|
// Strip leading path components like /rezepte/ or ./
|
||||||
|
href = href.replace(/^(\.?\/?rezepte\/|\.\/|\/)/, '');
|
||||||
|
// Skip if contains a dot (file extensions, external domains)
|
||||||
|
if (href.includes('.')) return null;
|
||||||
|
return href || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build nutrition totals for referenced recipes:
|
||||||
|
* 1. Base recipe references (type='reference' with populated baseRecipeRef)
|
||||||
|
* 2. Anchor-tag references in ingredient names (<a href=...>)
|
||||||
|
*/
|
||||||
|
async function resolveReferencedNutrition(
|
||||||
|
ingredients: any[],
|
||||||
|
): Promise<{ shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[]> {
|
||||||
|
const results: { shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[] = [];
|
||||||
|
const processedSlugs = new Set<string>();
|
||||||
|
|
||||||
|
for (const section of ingredients) {
|
||||||
|
// Type 1: Base recipe references
|
||||||
|
if (section.type === 'reference' && section.baseRecipeRef) {
|
||||||
|
const ref = section.baseRecipeRef;
|
||||||
|
const slug = ref.short_name;
|
||||||
|
if (processedSlugs.has(slug)) continue;
|
||||||
|
processedSlugs.add(slug);
|
||||||
|
|
||||||
|
if (ref.nutritionMappings?.length > 0) {
|
||||||
|
const mult = section.baseMultiplier || 1;
|
||||||
|
const nutrition = computeRecipeNutritionTotals(ref.ingredients || [], ref.nutritionMappings, 1);
|
||||||
|
results.push({ shortName: slug, name: ref.name, nutrition, baseMultiplier: mult });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type 2: Anchor-tag references in ingredient names
|
||||||
|
if (section.list) {
|
||||||
|
for (const item of section.list) {
|
||||||
|
const refSlug = parseAnchorRecipeRef(item.name || '');
|
||||||
|
if (!refSlug || processedSlugs.has(refSlug)) continue;
|
||||||
|
processedSlugs.add(refSlug);
|
||||||
|
|
||||||
|
// Look up the referenced recipe
|
||||||
|
const refRecipe = await Recipe.findOne({ short_name: refSlug })
|
||||||
|
.select('short_name name ingredients nutritionMappings portions')
|
||||||
|
.lean();
|
||||||
|
if (!refRecipe?.nutritionMappings?.length) continue;
|
||||||
|
|
||||||
|
const nutrition = computeRecipeNutritionTotals(
|
||||||
|
refRecipe.ingredients || [], refRecipe.nutritionMappings, 1
|
||||||
|
);
|
||||||
|
results.push({ shortName: refSlug, name: refRecipe.name, nutrition, baseMultiplier: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
await dbConnect();
|
await dbConnect();
|
||||||
const en = isEnglish(params.recipeLang!);
|
const en = isEnglish(params.recipeLang!);
|
||||||
|
|||||||
@@ -2,23 +2,19 @@ import { json, type RequestHandler } from '@sveltejs/kit';
|
|||||||
import { Recipe } from '$models/Recipe';
|
import { Recipe } from '$models/Recipe';
|
||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
||||||
import { resolveReferencedNutrition } from '$lib/server/nutritionMatcher';
|
|
||||||
import type { RecipeModelType } from '$types/types';
|
import type { RecipeModelType } from '$types/types';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||||
await dbConnect();
|
await dbConnect();
|
||||||
let recipe = (await Recipe.findOne({ short_name: params.name })
|
let recipe = (await Recipe.findOne({ short_name: params.name }).lean()) as unknown as RecipeModelType;
|
||||||
.populate({ path: 'ingredients.baseRecipeRef', select: 'short_name name ingredients nutritionMappings' })
|
|
||||||
.lean()) as unknown as RecipeModelType;
|
|
||||||
|
|
||||||
recipe = JSON.parse(JSON.stringify(recipe));
|
recipe = JSON.parse(JSON.stringify(recipe));
|
||||||
if (recipe == null) {
|
if (recipe == null) {
|
||||||
throw error(404, "Recipe not found");
|
throw error(404, "Recipe not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const referencedNutrition = await resolveReferencedNutrition(recipe.ingredients || []);
|
const jsonLd = generateRecipeJsonLd(recipe);
|
||||||
const jsonLd = generateRecipeJsonLd(recipe, referencedNutrition);
|
|
||||||
|
|
||||||
// Set appropriate headers for JSON-LD
|
// Set appropriate headers for JSON-LD
|
||||||
setHeaders({
|
setHeaders({
|
||||||
|
|||||||
Reference in New Issue
Block a user