75 lines
2.2 KiB
TypeScript
75 lines
2.2 KiB
TypeScript
|
|
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 };
|
||
|
|
}
|