feat(recipes): add recipe importer (preview + persist)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:11:23 +02:00
parent aea07c5eb2
commit 99afc45c29
2 changed files with 187 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
import type Database from 'better-sqlite3';
import type { Recipe } from '$lib/types';
import { fetchText } from '../http';
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
import { isDomainAllowed } from '../domains/whitelist';
import { downloadImage } from '../images/image-downloader';
import {
getRecipeById,
getRecipeIdBySourceUrl,
insertRecipe
} from './repository';
export class ImporterError extends Error {
constructor(
public readonly code:
| 'INVALID_URL'
| 'DOMAIN_BLOCKED'
| 'FETCH_FAILED'
| 'NO_RECIPE_FOUND',
message: string
) {
super(message);
this.name = 'ImporterError';
}
}
function hostnameOrThrow(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
throw new ImporterError('INVALID_URL', `Not a valid URL: ${url}`);
}
}
export async function previewRecipe(db: Database.Database, url: string): Promise<Recipe> {
const host = hostnameOrThrow(url);
if (!isDomainAllowed(db, url)) {
throw new ImporterError('DOMAIN_BLOCKED', `Domain not allowed: ${host}`);
}
let html: string;
try {
html = await fetchText(url);
} catch (e) {
throw new ImporterError('FETCH_FAILED', (e as Error).message);
}
const recipe = extractRecipeFromHtml(html);
if (!recipe) {
throw new ImporterError('NO_RECIPE_FOUND', 'No schema.org/Recipe JSON-LD on page');
}
recipe.source_url = url;
recipe.source_domain = host.replace(/^www\./, '');
return recipe;
}
export async function importRecipe(
db: Database.Database,
imageDir: string,
url: string
): Promise<{ id: number; duplicate: boolean; recipe: Recipe }> {
const existingId = getRecipeIdBySourceUrl(db, url);
if (existingId !== null) {
const recipe = getRecipeById(db, existingId);
if (recipe) return { id: existingId, duplicate: true, recipe };
}
const recipe = await previewRecipe(db, url);
let imageFilename: string | null = null;
if (recipe.image_path) {
imageFilename = await downloadImage(recipe.image_path, imageDir);
}
recipe.image_path = imageFilename;
const id = insertRecipe(db, recipe);
const persisted = getRecipeById(db, id);
return { id, duplicate: false, recipe: persisted ?? recipe };
}