Compare commits
2 Commits
5482f60a16
...
97969f8151
| Author | SHA1 | Date | |
|---|---|---|---|
|
97969f8151
|
|||
|
9b2325a0cb
|
@@ -26,7 +26,7 @@ function parseTimeToISO8601(timeString: string | undefined): string | undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
import type { RecipeModelType } from '$types/types';
|
||||
import type { RecipeModelType, NutritionMapping } from '$types/types';
|
||||
|
||||
interface HowToStep {
|
||||
"@type": "HowToStep";
|
||||
@@ -55,7 +55,14 @@ interface RecipeJsonLd {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function generateRecipeJsonLd(data: RecipeModelType) {
|
||||
type ReferencedNutrition = {
|
||||
shortName: string;
|
||||
name: string;
|
||||
nutrition: Record<string, number>;
|
||||
baseMultiplier: number;
|
||||
};
|
||||
|
||||
export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?: ReferencedNutrition[]) {
|
||||
const jsonLd: RecipeJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
@@ -144,6 +151,12 @@ export function generateRecipeJsonLd(data: RecipeModelType) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add nutrition information from stored mappings
|
||||
const nutritionInfo = computeNutritionInfo(data.ingredients || [], data.nutritionMappings, data.portions, referencedNutrition);
|
||||
if (nutritionInfo) {
|
||||
jsonLd.nutrition = nutritionInfo;
|
||||
}
|
||||
|
||||
// Clean up undefined values
|
||||
Object.keys(jsonLd).forEach(key => {
|
||||
if (jsonLd[key] === undefined) {
|
||||
@@ -153,3 +166,110 @@ export function generateRecipeJsonLd(data: RecipeModelType) {
|
||||
|
||||
return jsonLd;
|
||||
}
|
||||
|
||||
function parseAmount(amount: string): number {
|
||||
if (!amount?.trim()) return 0;
|
||||
const s = amount.trim().replace(',', '.');
|
||||
const rangeMatch = s.match(/^(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)$/);
|
||||
if (rangeMatch) return (parseFloat(rangeMatch[1]) + parseFloat(rangeMatch[2])) / 2;
|
||||
const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]);
|
||||
const mixedMatch = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
||||
if (mixedMatch) return parseInt(mixedMatch[1]) + parseInt(mixedMatch[2]) / parseInt(mixedMatch[3]);
|
||||
const parsed = parseFloat(s);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
function computeNutritionInfo(
|
||||
ingredients: any[],
|
||||
mappings: NutritionMapping[] | undefined,
|
||||
portions: string | undefined,
|
||||
referencedNutrition?: ReferencedNutrition[],
|
||||
): Record<string, string> | null {
|
||||
if ((!mappings || mappings.length === 0) && (!referencedNutrition || referencedNutrition.length === 0)) return null;
|
||||
|
||||
const index = new Map(
|
||||
mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
|
||||
);
|
||||
|
||||
const totals = { calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0, sodium: 0, cholesterol: 0 };
|
||||
|
||||
// Collect section names for dedup
|
||||
const sectionNames = new Set<string>();
|
||||
for (const section of ingredients) {
|
||||
if (section.name) sectionNames.add(stripHtml(section.name).toLowerCase().trim());
|
||||
}
|
||||
|
||||
for (let si = 0; si < ingredients.length; si++) {
|
||||
const section = ingredients[si];
|
||||
if (section.type === 'reference' || !section.list) continue;
|
||||
const currentSectionName = section.name ? stripHtml(section.name).toLowerCase().trim() : '';
|
||||
|
||||
for (let ii = 0; ii < section.list.length; ii++) {
|
||||
const item = section.list[ii];
|
||||
const rawName = item.name || '';
|
||||
const itemName = stripHtml(rawName).toLowerCase().trim();
|
||||
if (/<a\s/i.test(rawName)) continue;
|
||||
if (itemName && sectionNames.has(itemName) && itemName !== currentSectionName) continue;
|
||||
|
||||
const m = index.get(`${si}-${ii}`);
|
||||
if (!m || m.matchMethod === 'none' || m.excluded || !m.per100g) continue;
|
||||
|
||||
const amount = parseAmount(item.amount || '') || (m.defaultAmountUsed ? 1 : 0);
|
||||
const grams = amount * (m.gramsPerUnit || 0);
|
||||
const factor = grams / 100;
|
||||
|
||||
totals.calories += factor * m.per100g.calories;
|
||||
totals.protein += factor * m.per100g.protein;
|
||||
totals.fat += factor * m.per100g.fat;
|
||||
totals.saturatedFat += factor * m.per100g.saturatedFat;
|
||||
totals.carbs += factor * m.per100g.carbs;
|
||||
totals.fiber += factor * m.per100g.fiber;
|
||||
totals.sugars += factor * m.per100g.sugars;
|
||||
totals.sodium += factor * m.per100g.sodium;
|
||||
totals.cholesterol += factor * m.per100g.cholesterol;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Parse portion count for per-serving values
|
||||
const portionMatch = portions?.match(/^(\d+(?:[.,]\d+)?)/);
|
||||
const portionCount = portionMatch ? parseFloat(portionMatch[1].replace(',', '.')) : 0;
|
||||
const div = portionCount > 0 ? portionCount : 1;
|
||||
|
||||
const fmt = (val: number, unit: string) => `${Math.round(val / div)} ${unit}`;
|
||||
|
||||
const info: Record<string, string> = {
|
||||
'@type': 'NutritionInformation',
|
||||
calories: `${Math.round(totals.calories / div)} calories`,
|
||||
proteinContent: fmt(totals.protein, 'g'),
|
||||
fatContent: fmt(totals.fat, 'g'),
|
||||
saturatedFatContent: fmt(totals.saturatedFat, 'g'),
|
||||
carbohydrateContent: fmt(totals.carbs, 'g'),
|
||||
fiberContent: fmt(totals.fiber, 'g'),
|
||||
sugarContent: fmt(totals.sugars, 'g'),
|
||||
sodiumContent: fmt(totals.sodium, 'mg'),
|
||||
cholesterolContent: fmt(totals.cholesterol, 'mg'),
|
||||
};
|
||||
|
||||
if (portionCount > 0) {
|
||||
info.servingSize = `1 portion (${portionCount} total)`;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
@@ -132,7 +132,7 @@ async function getBlsEmbeddingIndex() {
|
||||
|
||||
/** Normalize an ingredient name for matching (English) */
|
||||
export function normalizeIngredientName(name: string): string {
|
||||
let normalized = name.toLowerCase().trim();
|
||||
let normalized = name.replace(/<[^>]*>/g, '').toLowerCase().trim();
|
||||
normalized = normalized.replace(/\(.*?\)/g, '').trim();
|
||||
for (const mod of STRIP_MODIFIERS) {
|
||||
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 */
|
||||
export function normalizeIngredientNameDe(name: string): string {
|
||||
let normalized = name.toLowerCase().trim();
|
||||
let normalized = name.replace(/<[^>]*>/g, '').toLowerCase().trim();
|
||||
normalized = normalized.replace(/\(.*?\)/g, '').trim();
|
||||
for (const mod of STRIP_MODIFIERS_DE) {
|
||||
normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim();
|
||||
@@ -280,7 +280,8 @@ function substringMatchScore(
|
||||
const pos = nameLower.indexOf(form);
|
||||
if (pos >= 0 && pos < 15) hasEarlyMatch = true;
|
||||
// Word-boundary match
|
||||
const wordBoundary = new RegExp(`(^|[\\s,/])${form}([\\s,/]|$)`);
|
||||
const escaped = form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const wordBoundary = new RegExp(`(^|[\\s,/])${escaped}([\\s,/]|$)`);
|
||||
if (wordBoundary.test(nameLower)) hasWordBoundaryMatch = true;
|
||||
}
|
||||
|
||||
@@ -813,3 +814,72 @@ export function computeRecipeNutritionTotals(
|
||||
function stripHtml(html: string): string {
|
||||
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 type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types';
|
||||
import { isEnglish } from '$lib/server/recipeHelpers';
|
||||
import { getNutritionEntryByFdcId, getBlsEntryByCode, computeRecipeNutritionTotals } from '$lib/server/nutritionMatcher';
|
||||
import { getNutritionEntryByFdcId, getBlsEntryByCode, resolveReferencedNutrition } from '$lib/server/nutritionMatcher';
|
||||
|
||||
/** Recursively map populated baseRecipeRef to resolvedRecipe field */
|
||||
function mapBaseRecipeRefs(items: any[]): any[] {
|
||||
@@ -42,72 +42,6 @@ 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 }) => {
|
||||
await dbConnect();
|
||||
const en = isEnglish(params.recipeLang!);
|
||||
|
||||
@@ -2,19 +2,23 @@ import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
|
||||
import { resolveReferencedNutrition } from '$lib/server/nutritionMatcher';
|
||||
import type { RecipeModelType } from '$types/types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, setHeaders }) => {
|
||||
await dbConnect();
|
||||
let recipe = (await Recipe.findOne({ short_name: params.name }).lean()) as unknown as RecipeModelType;
|
||||
let recipe = (await Recipe.findOne({ short_name: params.name })
|
||||
.populate({ path: 'ingredients.baseRecipeRef', select: 'short_name name ingredients nutritionMappings' })
|
||||
.lean()) as unknown as RecipeModelType;
|
||||
|
||||
recipe = JSON.parse(JSON.stringify(recipe));
|
||||
if (recipe == null) {
|
||||
throw error(404, "Recipe not found");
|
||||
}
|
||||
|
||||
const jsonLd = generateRecipeJsonLd(recipe);
|
||||
const referencedNutrition = await resolveReferencedNutrition(recipe.ingredients || []);
|
||||
const jsonLd = generateRecipeJsonLd(recipe, referencedNutrition);
|
||||
|
||||
// Set appropriate headers for JSON-LD
|
||||
setHeaders({
|
||||
|
||||
Reference in New Issue
Block a user