2026-04-17 15:11:23 +02:00
|
|
|
import type Database from 'better-sqlite3';
|
|
|
|
|
import type { Recipe } from '$lib/types';
|
|
|
|
|
import { fetchText } from '../http';
|
|
|
|
|
import { extractRecipeFromHtml } from '../parsers/json-ld-recipe';
|
|
|
|
|
import { downloadImage } from '../images/image-downloader';
|
|
|
|
|
import {
|
|
|
|
|
getRecipeById,
|
|
|
|
|
getRecipeIdBySourceUrl,
|
|
|
|
|
insertRecipe
|
|
|
|
|
} from './repository';
|
|
|
|
|
|
|
|
|
|
export class ImporterError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
public readonly code:
|
|
|
|
|
| 'INVALID_URL'
|
|
|
|
|
| '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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 15:18:46 +02:00
|
|
|
// Manuelle URL-Importe sind absichtlich NICHT mehr auf die allowed_domain-
|
|
|
|
|
// Whitelist beschränkt — der User pastet bewusst eine URL und erwartet,
|
|
|
|
|
// dass der Import klappt. Die Whitelist bleibt für die Web-Suche (searxng)
|
|
|
|
|
// relevant, weil dort ein breites Crawl-Feld eingeschränkt werden soll.
|
|
|
|
|
export async function previewRecipe(_db: Database.Database, url: string): Promise<Recipe> {
|
2026-04-17 15:11:23 +02:00
|
|
|
const host = hostnameOrThrow(url);
|
|
|
|
|
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 };
|
|
|
|
|
}
|