feat(recipes): add recipe importer (preview + persist)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
74
src/lib/server/recipes/importer.ts
Normal file
74
src/lib/server/recipes/importer.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user