UNPKG

recipe-scrapers-js

Version:
1,329 lines (1,299 loc) 44.7 kB
import * as cheerio from "cheerio"; import { parse, toSeconds } from "iso8601-duration"; import z from "zod/v4"; //#region src/utils/index.ts function isDefined(value) { return value !== void 0; } function isFunction(value) { return typeof value === "function"; } function isNumber(value) { return typeof value === "number"; } function isPlainObject(value) { return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype; } function isString(value) { return typeof value === "string"; } /** * Extracts the host name from a URL string * and removes 'www.' prefix if present. * Throws an error if the input is not a valid URL. */ function getHostName(value) { try { const url = new URL(value.replace("www.", "")); return url.host; } catch { throw new Error(`Invalid URL: ${value}`); } } //#endregion //#region src/exceptions/index.ts var ExtractorNotFoundException = class extends Error { constructor(field) { super(`No extractor found for field: ${field}`); this.name = "ExtractorNotFoundException"; } }; var NotImplementedException = class extends Error { constructor(method) { super(`Method should be implemented: ${method}`); this.name = "NotImplementedException"; } }; var UnsupportedFieldException = class extends Error { constructor(field) { super(`Extraction not supported for field: ${field}`); this.name = "UnsupportedFieldException"; } }; var ExtractionFailedException = class extends Error { constructor(field, value) { const msg = isDefined(value) ? `Invalid value for "${field}": ${String(value)}` : `No value found for "${field}"`; super(msg); this.name = "ExtractionFailedException"; } }; //#endregion //#region src/logger.ts let LogLevel = /* @__PURE__ */ function(LogLevel$1) { LogLevel$1[LogLevel$1["VERBOSE"] = 0] = "VERBOSE"; LogLevel$1[LogLevel$1["DEBUG"] = 1] = "DEBUG"; LogLevel$1[LogLevel$1["INFO"] = 2] = "INFO"; LogLevel$1[LogLevel$1["WARN"] = 3] = "WARN"; LogLevel$1[LogLevel$1["ERROR"] = 4] = "ERROR"; return LogLevel$1; }({}); var Logger = class { constructor(context, logLevel = LogLevel.WARN) { this.context = context; this.logLevel = logLevel; } verbose(...args) { if (this.logLevel > LogLevel.VERBOSE) return; console.log(`[VERBOSE][${this.context}]`, ...args); } debug(...args) { if (this.logLevel > LogLevel.DEBUG) return; console.debug(`[DEBUG][${this.context}]`, ...args); } log(...args) { if (this.logLevel > LogLevel.INFO) return; console.log(`[INFO][${this.context}]`, ...args); } info(...args) { if (this.logLevel > LogLevel.INFO) return; console.info(`[INFO][${this.context}]`, ...args); } warn(...args) { if (this.logLevel > LogLevel.WARN) return; console.warn(`[WARN][${this.context}]`, ...args); } error(...args) { console.error(`[ERROR][${this.context}]`, ...args); } }; //#endregion //#region src/plugin-manager.ts var PluginManager = class { extractorPlugins; postProcessorPlugins; constructor(baseExtractors, basePostProcessors, extraExtractors = [], extraPostProcessors = []) { this.extractorPlugins = [...baseExtractors, ...extraExtractors].sort((a, b) => b.priority - a.priority); this.postProcessorPlugins = [...basePostProcessors, ...extraPostProcessors].sort((a, b) => b.priority - a.priority); } getExtractors() { return this.extractorPlugins; } getPostProcessors() { return this.postProcessorPlugins; } }; //#endregion //#region src/abstract-postprocessor-plugin.ts var PostProcessorPlugin = class {}; //#endregion //#region src/utils/parsing.ts function normalizeString(str) { return str?.trim().replace(/\s+/g, " ").replace(/\s+,/g, ",") ?? ""; } function splitToList(value, separator) { if (!value) return []; const items = []; for (const item of value.split(separator)) { const str = normalizeString(item); if (str) items.push(str); } return items; } /** * @TODO Implement [Temporal.Duration](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration) once it lands. */ function parseMinutes(value) { const duration = parse(value); const totalSeconds = toSeconds(duration); return Math.round(totalSeconds / 60); } //#endregion //#region src/utils/ingredients.ts const DEFAULT_INGREDIENTS_GROUP_NAME = "Ingredients"; const DEFAULT_GROUPING_SELECTORS = { wprm: { headingSelectors: [".wprm-recipe-ingredient-group h4", ".wprm-recipe-group-name"], itemSelectors: [".wprm-recipe-ingredient", ".wprm-recipe-ingredients li"] }, tasty: { headingSelectors: [".tasty-recipes-ingredients-body p strong", ".tasty-recipes-ingredients h4"], itemSelectors: [".tasty-recipes-ingredients-body ul li", ".tasty-recipes-ingredients ul li"] } }; function isList(value) { return value instanceof Set && Array.from(value).every(isString); } function isIngredientGroup(value) { return value instanceof Map && Array.from(value.values()).every(isList); } function isIngredients(value) { return isList(value) || isIngredientGroup(value); } function ingredientsToObject(value) { if (isList(value)) return Array.from(value); if (isIngredientGroup(value)) { const obj = {}; for (const [group, ingredients] of value.entries()) obj[group] = Array.from(ingredients); return obj; } throw new Error("Invalid ingredients type"); } function scoreSentenceSimilarity(first, second) { if (first === second) return 1; if (first.length < 2 || second.length < 2) return 0; const bigrams = (s) => new Set(Array.from({ length: s.length - 1 }, (_, i) => s.slice(i, i + 2))); const firstBigrams = bigrams(first); const secondBigrams = bigrams(second); const intersectionSize = [...firstBigrams].filter((b) => secondBigrams.has(b)).length; return 2 * intersectionSize / (firstBigrams.size + secondBigrams.size); } function bestMatch(testString, targetStrings) { if (targetStrings.length === 0) throw new Error("targetStrings cannot be empty"); const scores = targetStrings.map((t) => scoreSentenceSimilarity(testString, t)); let bestIndex = 0; let bestScore = scores[0]; for (let i = 1; i < scores.length; i++) if (scores[i] > bestScore) { bestScore = scores[i]; bestIndex = i; } return targetStrings[bestIndex]; } function findSelectors($, initialHeading, initialItem) { if (initialHeading && initialItem) { if ($(initialHeading).length && $(initialItem).length) return [initialHeading, initialItem]; return null; } const values = Object.values(DEFAULT_GROUPING_SELECTORS); for (const { headingSelectors, itemSelectors } of values) for (const heading of headingSelectors) for (const item of itemSelectors) if ($(heading).length && $(item).length) return [heading, item]; return null; } /** * Groups ingredients based on the provided selectors. * If no selectors are provided, it will try to find the best matching * selectors from the default grouping selectors. * * @param $ Cheerio instance * @param ingredients Ingredients extracted from plugins, if any * @param headingSelector * @param itemSelector */ function groupIngredients($, ingredientsList, headingSelector, itemSelector) { const selectors = findSelectors($, headingSelector, itemSelector); if (!selectors) return ingredientsList; const [groupNameSelector, ingredientSelector] = selectors; const foundIngredients = new Set($(ingredientSelector).toArray().map((el) => $(el).text().trim()).filter(Boolean)); if (foundIngredients.size !== ingredientsList.size) throw new Error(`Found ${foundIngredients.size} grouped ingredients but was expecting to find ${ingredientsList.size}.`); const ingredients = Array.from(ingredientsList); const groupings = /* @__PURE__ */ new Map(); let currentHeading = null; const elements = $(`${groupNameSelector}, ${ingredientSelector}`).toArray(); for (const el of elements) { const $el = $(el); if ($el.is(groupNameSelector)) { const headingText = normalizeString($el.text()); currentHeading = headingText || DEFAULT_INGREDIENTS_GROUP_NAME; if (!groupings.has(currentHeading)) groupings.set(currentHeading, /* @__PURE__ */ new Set()); } else if ($el.is(ingredientSelector)) { const text = normalizeString($el.text()); if (!text) continue; const matched = bestMatch(text, ingredients); const heading = currentHeading || DEFAULT_INGREDIENTS_GROUP_NAME; if (!groupings.has(heading)) groupings.set(heading, /* @__PURE__ */ new Set()); groupings.get(heading)?.add(matched); } } return groupings; } //#endregion //#region src/plugins/html-stripper.processor.ts var HtmlStripperPlugin = class extends PostProcessorPlugin { name = "HtmlStripper"; priority = 100; fieldsToProcess = [ "title", "description", "instructions", "ingredients" ]; shouldProcess(field) { return this.fieldsToProcess.includes(field); } process(field, value) { if (!this.shouldProcess(field)) return value; if (isString(value)) return this.stripHtml(value); if (field === "instructions" && isList(value)) { const result = Array.from(value).map(this.stripHtml); return new Set(result); } if (field === "ingredients" && isIngredients(value)) return this.processIngredients(value); return value; } processIngredients(ingredients) { if (isList(ingredients)) { const processedIngredients = Array.from(ingredients).map(this.stripHtml); return new Set(processedIngredients); } if (isIngredientGroup(ingredients)) { const processedGroup = /* @__PURE__ */ new Map(); for (const [groupName, ingredientSet] of ingredients) { const processedGroupName = this.stripHtml(groupName); const processedIngredients = Array.from(ingredientSet).map(this.stripHtml); processedGroup.set(processedGroupName, new Set(processedIngredients)); } return processedGroup; } return ingredients; } stripHtml(html) { return html.replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#34;/g, "\"").replace(/&#39;/g, "'").trim(); } }; //#endregion //#region src/abstract-plugin.ts var AbstractPlugin = class { constructor($) { this.$ = $; } }; //#endregion //#region src/abstract-extractor-plugin.ts var ExtractorPlugin = class extends AbstractPlugin {}; //#endregion //#region src/plugins/opengraph.extractor.ts var OpenGraphException = class extends ExtractionFailedException { constructor(name) { super(name); this.name = "OpenGraphException"; } }; var OpenGraphPlugin = class OpenGraphPlugin extends ExtractorPlugin { name = OpenGraphPlugin.name; priority = 60; extractors = { image: this.image.bind(this), siteName: this.siteName.bind(this) }; supports(field) { return Object.keys(this.extractors).includes(field); } extract(field) { const extractor = this.extractors[field]; if (!extractor) throw new ExtractorNotFoundException(field); return extractor(); } siteName() { const meta = this.$("meta[property=\"og:site_name\"]").attr("content") || this.$("meta[name=\"og:site_name\"]").attr("content"); if (!meta) throw new OpenGraphException("siteName"); return meta; } image() { const image = this.$("meta[property=\"og:image\"][content]").attr("content"); if (!image?.startsWith("http")) throw new OpenGraphException("image"); return image; } }; //#endregion //#region src/utils/instructions.ts /** * List of possible headings to remove from instructions. */ const INSTRUCTION_HEADINGS = [ "Preparation", "Directions", "Instructions", "Method", "Steps" ]; /** * Removes any heading from the start of the instructions string. */ function removeInstructionHeading(value) { for (const heading of INSTRUCTION_HEADINGS) { const regex = new RegExp(`^\\s*${heading}\\s*:?\\s*`, "i"); if (regex.test(value)) return value.replace(regex, ""); } return value; } /** * Splits a recipe instructions string into an array of steps. * Removes known headings and trims whitespace. */ function splitInstructions(value) { if (!value) return []; const cleaned = removeInstructionHeading(value).trim(); let steps = splitToList(cleaned, /\n\s*\n+/); if (steps.length === 1) steps = splitToList(cleaned, /(?<=\.)\s+(?=[A-Z])/); return steps; } //#endregion //#region src/utils/microdata.ts const addProperty = (obj, key, value) => { if (obj[key] === void 0) obj[key] = value; else if (Array.isArray(obj[key])) obj[key].push(value); else obj[key] = [obj[key], value]; }; const extractValueFromElement = (element) => { if (element.is("meta")) return element.attr("content"); if (element.is("time")) return element.attr("datetime") || element.text().trim(); if (element.is("img")) return element.attr("src"); if (element.is("a")) return element.attr("href"); return element.text().trim(); }; const extractSchemaType = (itemType) => { const typeMatch = itemType.match(/schema\.org\/(\w+)/); return typeMatch?.[1]; }; /** * Extracts microdata from HTML elements using itemtype and itemprop attributes * * @param $ - Cheerio instance * @param selector - Selector to find elements with microdata * @returns Array of extracted microdata objects */ function extractMicrodata($, selector) { const results = []; const elements = $(selector); elements.each((_, el) => { const $element = $(el); const itemType = $element.attr("itemtype"); const rootObject = {}; if (itemType) { const schemaType = extractSchemaType(itemType); if (schemaType) rootObject["@type"] = schemaType; } const allProps = $element.find("[itemprop]").addBack("[itemprop]"); const nestedItemTypes = $element.find("[itemtype]"); const rootLevelProps = allProps.filter((_$1, propEl) => { const $prop = $(propEl); if ($prop.attr("itemtype")) return true; const isInsideNestedType = nestedItemTypes.toArray().some((nestedEl) => $(nestedEl).find($prop).length > 0); return !isInsideNestedType; }); rootLevelProps.each((_$1, propEl) => { const $prop = $(propEl); const propName = $prop.attr("itemprop"); if (!propName) return; let propValue; const nestedItemType = $prop.attr("itemtype"); if (nestedItemType) { const nestedObject = {}; const nestedSchemaType = extractSchemaType(nestedItemType); if (nestedSchemaType) nestedObject["@type"] = nestedSchemaType; $prop.find("[itemprop]").each((_$2, nestedEl) => { const $nested = $(nestedEl); const nestedProp = $nested.attr("itemprop"); if (!nestedProp) return; const nestedValue = extractValueFromElement($nested); if (nestedValue && nestedValue !== "") addProperty(nestedObject, nestedProp, nestedValue); }); propValue = nestedObject; } else propValue = extractValueFromElement($prop); if (propValue !== void 0 && propValue !== "") addProperty(rootObject, propName, propValue); }); if (Object.keys(rootObject).length > 1 || Object.keys(rootObject).length === 1 && !rootObject["@type"]) results.push(rootObject); }); return results; } /** * Extracts Recipe microdata specifically * * @param $ - Cheerio instance * @returns Array of recipe microdata objects */ function extractRecipeMicrodata($) { return extractMicrodata($, "[itemtype*=\"schema.org/Recipe\"], [itemtype*=\"Recipe\"]"); } //#endregion //#region src/utils/parse-yields.ts const SERVE_REGEX_NUMBER = /(?:\D*(?<items>\d+(?:\.\d*)?)\D*)/; const SERVE_REGEX_ITEMS = /\bsandwiches\b|\btacquitos\b|\bmakes\b|\bcups\b|\bappetizer\b|\bporzioni\b|\bcookies\b|\b(large |small )?buns\b/gi; const SERVE_REGEX_TO = /\d+(\s+to\s+|-)\d+/gi; const RECIPE_YIELD_TYPES = [ ["dozen", "dozen"], ["batch", "batches"], ["cake", "cakes"], ["sandwich", "sandwiches"], ["bun", "buns"], ["cookie", "cookies"], ["muffin", "muffins"], ["cupcake", "cupcakes"], ["loaf", "loaves"], ["pie", "pies"], ["cup", "cups"], ["pint", "pints"], ["gallon", "gallons"], ["ounce", "ounces"], ["pound", "pounds"], ["gram", "grams"], ["liter", "liters"], ["piece", "pieces"], ["layer", "layers"], ["scoop", "scoops"], ["bar", "bars"], ["patty", "patties"], ["hamburger bun", "hamburger buns"], ["pancake", "pancakes"], ["item", "items"] ]; /** * Returns a string of servings or items. If the recipe is for a number of * items (not servings), it returns "x item(s)" where x is the quantity. * This function handles cases where the yield is in dozens, * such as "4 dozen cookies", returning "4 dozen" instead of "4 servings". * Additionally accommodates yields specified in batches * (e.g., "2 batches of brownies"), returning the yield as stated. * * @param value The yield string from the recipe * @returns The number of servings, items, dozen, batches, etc... */ function parseYields(element) { if (!element) throw new Error("Element is required"); let serveText = element; if (SERVE_REGEX_TO.test(serveText)) { const splitMatch = serveText.match(SERVE_REGEX_TO); if (splitMatch && splitMatch.index !== void 0) serveText = serveText.slice(splitMatch.index + splitMatch[0].length).trim(); } const match = serveText.match(SERVE_REGEX_NUMBER); const matched = match?.groups?.items || "0"; const serveTextLower = serveText.toLowerCase(); let bestMatch$1 = null; let bestMatchLength = 0; for (const [singular, plural$1] of RECIPE_YIELD_TYPES) if (serveTextLower.includes(singular) || serveTextLower.includes(plural$1)) { const matchLength = serveTextLower.includes(singular) ? singular.length : plural$1.length; if (matchLength > bestMatchLength) { bestMatchLength = matchLength; bestMatch$1 = `${matched} ${Number.parseFloat(matched) === 1 ? singular : plural$1}`; } } if (bestMatch$1) { const parenMatch = serveText.match(/\(.*\)/); if (parenMatch) bestMatch$1 += ` ${parenMatch[0]}`; return bestMatch$1; } const plural = Number.parseFloat(matched) > 1 || Number.parseFloat(matched) === 0 ? "s" : ""; if (SERVE_REGEX_ITEMS.test(serveText)) return `${matched} item${plural}`; return `${matched} serving${plural}`; } //#endregion //#region src/plugins/schema-org.extractor/type-predicates.ts function hasId(obj) { return "@id" in obj && typeof obj["@id"] === "string"; } function isGraphType(obj) { return isPlainObject(obj) && "@graph" in obj && Array.isArray(obj["@graph"]); } function isBaseType(obj) { return isPlainObject(obj) && "@type" in obj && (isString(obj["@type"]) || Array.isArray(obj["@type"])); } function isSchemaOrgData(obj) { return isGraphType(obj) || isBaseType(obj); } function isThingType(obj, type) { if (!isBaseType(obj)) return false; const thingType = Array.isArray(obj["@type"]) ? obj["@type"][0] : obj["@type"]; return thingType === type; } function isAggregateRating(obj) { return isThingType(obj, "AggregateRating"); } function isHowToSection(obj) { return isThingType(obj, "HowToSection"); } function isHowToStep(obj) { return isThingType(obj, "HowToStep"); } function isOrganization(obj) { return isThingType(obj, "Organization"); } function isPerson(obj) { return isThingType(obj, "Person"); } function isRecipe(obj) { return isThingType(obj, "Recipe"); } function isWebPage(obj) { return isThingType(obj, "WebPage"); } function isWebSite(obj) { return isThingType(obj, "WebSite"); } //#endregion //#region src/plugins/schema-org.extractor/index.ts var SchemaOrgException = class extends ExtractionFailedException { constructor(field, value) { super(field, value); this.name = "SchemaOrgException"; } }; var SchemaOrgPlugin = class SchemaOrgPlugin extends ExtractorPlugin { name = SchemaOrgPlugin.name; priority = 90; logger; schemaData = []; recipe = { "@type": "Recipe" }; people = {}; ratingsData = {}; websiteName = null; extractors = { siteName: this.siteName.bind(this), language: this.language.bind(this), title: this.title.bind(this), author: this.author.bind(this), description: this.description.bind(this), image: this.image.bind(this), ingredients: this.ingredients.bind(this), instructions: this.instructions.bind(this), category: this.category.bind(this), yields: this.yields.bind(this), totalTime: this.totalTime.bind(this), cookTime: this.cookTime.bind(this), prepTime: this.prepTime.bind(this), cuisine: this.cuisine.bind(this), cookingMethod: this.cookingMethod.bind(this), ratings: this.ratings.bind(this), ratingsCount: this.ratingsCount.bind(this), nutrients: this.nutrients.bind(this), keywords: this.keywords.bind(this), dietaryRestrictions: this.dietaryRestrictions.bind(this) }; constructor($, logLevel) { super($); this.logger = new Logger(SchemaOrgPlugin.name, logLevel); this.extractJsonLdData(); this.extractMicrodataData(); this.processSchemaData(); } supports(field) { return Object.keys(this.extractors).includes(field); } extract(field) { const extractor = this.extractors[field]; if (!isFunction(extractor)) throw new UnsupportedFieldException(field); return extractor(); } /** * Extracts structured JSON-LD data from the page. */ extractJsonLdData() { this.$("script[type=\"application/ld+json\"]").each((_, el) => { try { const json = this.$(el).html()?.trim(); if (json) { const data = JSON.parse(json); if (Array.isArray(data)) { for (const item of data) if (isSchemaOrgData(item)) this.schemaData.push(item); } else if (isSchemaOrgData(data)) this.schemaData.push(data); } } catch (error) { this.logger.warn("Failed to parse JSON-LD", error); } }); } /** * Extracts microdata from the page. */ extractMicrodataData() { const microdataObjects = extractRecipeMicrodata(this.$); for (const obj of microdataObjects) this.schemaData.push(obj); } pickFromObject(obj, props) { if (!isPlainObject(obj)) return void 0; for (const prop of props) if (isString(obj[prop])) return obj[prop]; return void 0; } getSchemaTextValue(value, props = [ "textValue", "name", "title", "@id" ]) { let text; if (isString(value)) text = value; else if (isNumber(value)) text = value.toString(); else if (Array.isArray(value)) text = this.getSchemaTextValue(value[0], props); else text = this.pickFromObject(value, props); return normalizeString(text); } schemaValueToList(value) { let list = []; if (Array.isArray(value)) for (const item of value) { const itemValue = this.getSchemaTextValue(item); if (itemValue) list.push(itemValue); } else if (isString(value)) list = splitToList(this.getSchemaTextValue(value), ","); return new Set(list); } findEntity(item, schemaType) { if (isThingType(item, schemaType)) return item; if (isGraphType(item)) { for (const graphItem of item["@graph"]) if (isThingType(graphItem, schemaType)) return graphItem; } return null; } getIdOrUrl(obj) { return hasId(obj) ? obj["@id"] : isString(obj.url) ? obj.url : null; } processSchemaData() { for (const item of this.schemaData) if (isGraphType(item)) for (const graphItem of item["@graph"]) this.processSchemaItem(graphItem); else this.processSchemaItem(item); } processSchemaItem(obj) { if (isRecipe(obj)) return this.processRecipe(obj); return this.processNonRecipeThing(obj); } processRecipe(obj) { const recipe = this.findEntity(obj, "Recipe"); this.recipe = { ...this.recipe, ...recipe }; } processNonRecipeThing(obj) { if (isWebSite(obj)) this.websiteName = this.getSchemaTextValue(obj); if (isWebPage(obj) && isBaseType(obj.mainEntity)) this.processSchemaItem(obj.mainEntity); if (isPerson(obj)) { const key = this.getIdOrUrl(obj); if (key) this.people[key] = obj; } if (isAggregateRating(obj)) { const key = obj["@id"]; if (key) this.ratingsData[key] = obj; } } parseDurationField(key) { const value = this.recipe[key]; if (!value) return null; if (isNumber(value)) { this.logger.warn(`Duration field "${key}" is a number: ${value}`); return value; } if (isString(value)) return parseMinutes(value); if (isBaseType(value) && "maxValue" in value) { const maxValue = this.getSchemaTextValue(value.maxValue); return parseMinutes(maxValue); } return null; } parseInstructions(value) { if (isString(value)) return new Set(splitInstructions(value)); const instructions = Array.isArray(value) ? value.flat() : [value].flat(); const result = []; for (const item of instructions) { const name = this.getSchemaTextValue(item, ["name"]); const text = this.getSchemaTextValue(item, ["text"]); if (isString(item)) result.push(normalizeString(item)); else if (isHowToStep(item)) { if (name && text && !text.startsWith(name.replace(/\.$/, ""))) result.push(name); if (text) result.push(text); } else if (isHowToSection(item)) { if (name) result.push(name); if (item.itemListElement) result.push(...this.parseInstructions(item.itemListElement)); } else if (text) result.push(text); } return new Set(result.filter(Boolean)); } /***************************************************************************** * Extractor methods ****************************************************************************/ siteName() { if (isOrganization(this.recipe.publisher)) { const publisherName = this.getSchemaTextValue(this.recipe.publisher, ["name", "alternateName"]); if (publisherName) return publisherName; } if (!this.websiteName) throw new SchemaOrgException("siteName"); return this.websiteName; } language() { const language = this.getSchemaTextValue(this.recipe.inLanguage); if (!language) throw new SchemaOrgException("language"); return language; } title() { const title = this.getSchemaTextValue(this.recipe.name); if (!title) throw new SchemaOrgException("title"); return title; } author() { let author = this.recipe.author; let authorName; if (Array.isArray(author) && author.length > 0) author = author[0]; if (isBaseType(author)) { const key = this.getIdOrUrl(author); if (key && this.people[key]) author = this.people[key]; authorName = author.name?.toString(); } authorName = normalizeString(authorName); if (!authorName) throw new SchemaOrgException("author"); return authorName; } description() { const desc = this.getSchemaTextValue(this.recipe.description); if (!desc) throw new SchemaOrgException("description"); return desc; } image() { const image = this.getSchemaTextValue(this.recipe.image, ["url", "contentUrl"]); if (!image.startsWith("http")) throw new SchemaOrgException("image", image); return image; } ingredients() { const ingredients = this.recipe.recipeIngredient ?? this.recipe.ingredients ?? []; if (!Array.isArray(ingredients)) throw new SchemaOrgException("ingredients", ingredients); const flatIngredients = ingredients.flat(); const cleanedIngredients = []; for (const item of flatIngredients) { const ingredient = this.getSchemaTextValue(item).replace(/\(\(/g, "(").replace(/\)\)/g, ")"); if (ingredient) cleanedIngredients.push(ingredient); } return new Set(cleanedIngredients); } instructions() { const instructions = this.parseInstructions(this.recipe.recipeInstructions); if (instructions.size === 0) throw new SchemaOrgException("instructions"); return instructions; } category() { const category = this.recipe.recipeCategory; if (!category) throw new SchemaOrgException("category"); return this.schemaValueToList(category); } yields() { const yields = this.getSchemaTextValue(this.recipe.recipeYield ?? this.recipe.yield); if (!yields) throw new SchemaOrgException("yields", yields); return parseYields(yields); } totalTime() { const totalTime = this.parseDurationField("totalTime"); if (totalTime) return totalTime; const prepTime = this.parseDurationField("prepTime") ?? 0; const cookTime = this.parseDurationField("cookTime") ?? 0; if (prepTime || cookTime) return prepTime + cookTime; throw new SchemaOrgException("totalTime"); } cookTime() { return this.parseDurationField("cookTime"); } prepTime() { return this.parseDurationField("prepTime"); } cuisine() { const cuisine = this.recipe.recipeCuisine; if (!cuisine) throw new SchemaOrgException("cuisine"); return this.schemaValueToList(cuisine); } cookingMethod() { const cookingMethod = this.getSchemaTextValue(this.recipe.cookingMethod); if (!cookingMethod) throw new SchemaOrgException("cookingMethod"); return cookingMethod; } ratings() { let ratings = this.recipe.aggregateRating ?? this.findEntity(this.recipe, "AggregateRating"); let ratingValue; if (isAggregateRating(ratings)) { const ratingId = ratings["@id"]; if (ratingId && this.ratingsData[ratingId]) ratings = this.ratingsData[ratingId]; ratingValue = this.getSchemaTextValue(ratings.ratingValue); } if (!ratingValue) throw new SchemaOrgException("ratings"); return Math.round(Number.parseFloat(ratingValue) * 100) / 100; } ratingsCount() { let ratings = this.recipe.aggregateRating ?? this.findEntity(this.recipe, "AggregateRating"); let ratingsCount; if (isAggregateRating(ratings)) { const ratingId = ratings["@id"]; if (ratingId && this.ratingsData[ratingId]) ratings = this.ratingsData[ratingId]; ratingsCount = this.getSchemaTextValue(ratings.ratingCount) || this.getSchemaTextValue(ratings.reviewCount); } if (!ratingsCount) throw new SchemaOrgException("ratingsCount"); const count = Number.parseFloat(ratingsCount); return count !== 0 ? Math.floor(count) : 0; } nutrients() { const nutrients = this.recipe.nutrition; if (!isPlainObject(nutrients)) throw new SchemaOrgException("nutrients", nutrients); const cleanedNutrients = /* @__PURE__ */ new Map(); for (const [key, value] of Object.entries(nutrients)) { if (!key || key.startsWith("@") || !value) continue; cleanedNutrients.set(key, this.getSchemaTextValue(value)); } return cleanedNutrients; } keywords() { const keywords = this.recipe.keywords; if (!keywords) throw new SchemaOrgException("keywords"); return this.schemaValueToList(keywords); } dietaryRestrictions() { const dietaryRestrictions = this.recipe.suitableForDiet; if (!dietaryRestrictions) throw new SchemaOrgException("dietaryRestrictions"); const restrictionList = /* @__PURE__ */ new Set(); const list = this.schemaValueToList(dietaryRestrictions); for (const item of list) { const value = item.replace(/^https?:\/\/schema\.org\//, ""); if (value) restrictionList.add(value); } return restrictionList; } }; //#endregion //#region src/constants.ts const OPTIONAL_RECIPE_FIELD_DEFAULT_VALUES = { siteName: null, category: /* @__PURE__ */ new Set(), cookTime: null, prepTime: null, totalTime: null, cuisine: /* @__PURE__ */ new Set(), cookingMethod: null, ratings: 0, ratingsCount: 0, equipment: /* @__PURE__ */ new Set(), reviews: /* @__PURE__ */ new Map(), nutrients: /* @__PURE__ */ new Map(), dietaryRestrictions: /* @__PURE__ */ new Set(), keywords: /* @__PURE__ */ new Set() }; //#endregion //#region src/recipe-extractor.ts var RecipeExtractor = class RecipeExtractor { logger; constructor(plugins, scraperName, options = {}) { this.plugins = plugins; this.scraperName = scraperName; this.options = options; this.logger = new Logger(this.getContext(), this.options.logLevel); this.plugins.sort((a, b) => b.priority - a.priority); } getContext(context) { return `${this.scraperName}.${RecipeExtractor.name}${context ? `.${context}` : ""}`; } async extract(field, extractor) { let result; this.logger.debug(`Extracting field: ${field}`); for (const plugin of this.plugins) { const pluginLogger = new Logger(this.getContext(plugin.name), this.options.logLevel); const isSupported = plugin.supports(field); if (isSupported && !isDefined(result)) try { result = await plugin.extract(field); } catch (err) { if (err instanceof ExtractionFailedException) pluginLogger.verbose(err.message); else pluginLogger.error(err); } else pluginLogger.verbose(`Field is not supported: ${field}`); } if (extractor) { this.logger.debug(`Using site-specific extractor for: ${field}`); this.logger.verbose("Current result: ", result); try { result = await extractor(result); this.logger.verbose(`Site result for ${field}: `, result); } catch (err) { this.logger.error(err); } } if (!result && field in OPTIONAL_RECIPE_FIELD_DEFAULT_VALUES) { this.logger.debug(`Using default value for: ${field}`); result = OPTIONAL_RECIPE_FIELD_DEFAULT_VALUES[field]; } if (isDefined(result)) return result; throw new ExtractorNotFoundException(field); } }; //#endregion //#region src/abstract-scraper.ts var AbstractScraper = class { logger; pluginManager; recipeExtractor; $; recipeData = null; constructor(html, url, options = {}) { this.html = html; this.url = url; this.options = options; const { extraExtractors = [], extraPostProcessors = [], logLevel = LogLevel.WARN } = options; this.logger = new Logger(this.constructor.name, logLevel); this.$ = cheerio.load(html); const baseExtractors = [new OpenGraphPlugin(this.$), new SchemaOrgPlugin(this.$, logLevel)]; const basePostProcessors = [new HtmlStripperPlugin()]; this.pluginManager = new PluginManager(baseExtractors, basePostProcessors, extraExtractors, extraPostProcessors); this.recipeExtractor = new RecipeExtractor(this.pluginManager.getExtractors(), this.constructor.name, { logLevel }); } /** * Main extraction method - tries site-specific first, then plugins, * then applies post-processing. */ async extract(field) { let value = await this.recipeExtractor.extract(field, this.extractors[field]); for (const processor of this.pluginManager.getPostProcessors()) value = await processor.process(field, value); return value; } /** * Static method to get the host of the scraper. * This should be implemented by subclasses to return the specific host. */ static host() { throw new NotImplementedException("host"); } /***************************************************************************** * Default implementations for common fields that can be overridden * by subclasses. ****************************************************************************/ canonicalUrl() { const canonicalLink = this.$("link[rel=\"canonical\"]").attr("href"); const base = new URL(this.url.startsWith("http") ? this.url : `https://${this.url}`); return canonicalLink ? new URL(canonicalLink, base).href : base.href; } language() { const langAttr = this.$("html").attr("lang"); if (langAttr) return langAttr; const metaLang = this.$("meta[http-equiv=\"content-language\"]").attr("content"); if (metaLang) return metaLang.split(",")[0]; this.logger.warn("Could not determine language"); return "en"; } links() { if (!this.options.linksEnabled) return []; return this.$("a[href]").map((_, el) => { const href = this.$(el).attr("href"); if (!href?.startsWith("http")) return null; return { href, text: this.$(el).text().trim() }; }).get().filter(Boolean); } /** * Scrape's the recipe and caches the data. */ async scrape() { const instance = this.constructor; if (this.recipeData) return this.recipeData; this.recipeData = { author: await this.extract("author"), canonicalUrl: this.canonicalUrl(), category: await this.extract("category"), cookTime: await this.extract("cookTime"), cookingMethod: await this.extract("cookingMethod"), cuisine: await this.extract("cuisine"), description: await this.extract("description"), dietaryRestrictions: await this.extract("dietaryRestrictions"), equipment: await this.extract("equipment"), host: instance.host(), image: await this.extract("image"), ingredients: await this.extract("ingredients"), instructions: await this.extract("instructions"), keywords: await this.extract("keywords"), language: this.language(), links: this.links(), nutrients: await this.extract("nutrients"), prepTime: await this.extract("prepTime"), ratings: await this.extract("ratings"), ratingsCount: await this.extract("ratingsCount"), reviews: await this.extract("reviews"), siteName: await this.extract("siteName"), title: await this.extract("title"), totalTime: await this.extract("totalTime"), yields: await this.extract("yields") }; return this.recipeData; } /** * Converts the scraper's data into a JSON-serializable object. */ async toObject() { const { category, cuisine, dietaryRestrictions, equipment, ingredients, instructions, keywords, nutrients, reviews,...rest } = await this.scrape(); return { ...rest, category: Array.from(category), cuisine: Array.from(cuisine), dietaryRestrictions: Array.from(dietaryRestrictions), equipment: Array.from(equipment), ingredients: ingredientsToObject(ingredients), instructions: Array.from(instructions), keywords: Array.from(keywords), nutrients: Object.fromEntries(nutrients), reviews: Object.fromEntries(reviews) }; } }; //#endregion //#region src/scrapers/allrecipes.ts var AllRecipes = class extends AbstractScraper { static host() { return "allrecipes.com"; } extractors = {}; }; //#endregion //#region src/scrapers/americastestkitchen.ts const recipeIngredientItemSchema = z.object({ fields: z.object({ qty: z.string(), preText: z.string(), postText: z.string(), measurement: z.string().nullable(), pluralIngredient: z.boolean(), ingredient: z.object({ contentType: z.string(), fields: z.object({ title: z.string(), pluralTitle: z.string(), kind: z.string() }) }) }) }); const recipeIngredientGroupSchema = z.object({ fields: z.object({ title: z.string(), recipeIngredientItems: z.array(recipeIngredientItemSchema) }) }); const recipeInstructionSchema = z.object({ fields: z.object({ content: z.string() }) }); const recipeDataSchema = z.object({ totalCookTime: z.number(), recipeTimeNote: z.string().optional(), ingredientGroups: z.array(recipeIngredientGroupSchema), headnote: z.string().optional(), instructions: z.array(recipeInstructionSchema), metaData: z.object({ fields: z.object({ photo: z.object({ url: z.url() }) }) }) }); const pagePropsDataSchema = z.object({ props: z.object({ pageProps: z.object({ data: recipeDataSchema }) }) }); var AmericasTestKitchen = class extends AbstractScraper { data = null; static host() { return "americastestkitchen.com"; } extractors = { image: this.image.bind(this), ingredients: this.ingredients.bind(this), instructions: this.instructions.bind(this), siteName: this.siteName.bind(this) }; siteName() { return "America's Test Kitchen"; } image(prevValue) { const data = this.getRecipeData(); if (!data) { if (prevValue) return prevValue; throw new Error("Failed to extract image"); } return data.metaData.fields.photo.url; } ingredients(prevValue) { let ingredients = this.parseIngredients(); if (!ingredients) ingredients = this.parseHtmlIngredients(prevValue); if (!ingredients) throw new Error("Failed to extract ingredients"); return ingredients; } instructions(prevValue) { const data = this.getRecipeData(); if (!data) { if (prevValue) return prevValue; throw new Error("Failed to extract instructions"); } const { headnote } = data; let headnoteText = ""; if (headnote) headnoteText = `Note: ${normalizeString(headnote)}`; const instructionTexts = []; for (const instruction of data.instructions) instructionTexts.push(normalizeString(instruction.fields.content)); return new Set([headnoteText, ...instructionTexts]); } parseHtmlIngredients(prevValue) { const headingSelector = "[class*=\"RecipeIngredientGroups_group\"] > span"; const ingredientSelector = "[class*=\"RecipeIngredient\"] label"; if (isList(prevValue) && prevValue.size > 0) { const result = groupIngredients(this.$, prevValue, headingSelector, ingredientSelector); return result; } return null; } getRecipeData() { if (this.data === null) { const jsonElement = this.$("script[type=\"application/json\"]"); const jsonString = jsonElement.html(); if (!jsonString) { this.logger.warn("Could not find JSON data script tag"); return null; } try { const parsed = pagePropsDataSchema.parse(JSON.parse(jsonString)); this.data = parsed.props.pageProps.data; } catch (error) { this.logger.error("Failed to parse JSON data:", error); return null; } } return this.data; } parseIngredientItem(ingredientItem) { const { fields } = ingredientItem; const fragments = [ fields.qty || "", fields.measurement || "", fields.ingredient.fields.title || "", fields.postText || "" ]; const filteredFragments = []; for (const fragment of fragments) if (fragment) filteredFragments.push(fragment.trimEnd()); return filteredFragments.join(" ").trimEnd().replace(" ,", ","); } parseIngredients() { const data = this.getRecipeData(); if (!data) return null; const { ingredientGroups } = data; if (ingredientGroups.length === 1) { const ingredientSet = /* @__PURE__ */ new Set(); for (const item of ingredientGroups[0].fields.recipeIngredientItems) ingredientSet.add(this.parseIngredientItem(item)); return ingredientSet; } const ingredientMap = /* @__PURE__ */ new Map(); for (const group of ingredientGroups) { const groupTitle = group.fields.title || DEFAULT_INGREDIENTS_GROUP_NAME; const ingredientSet = /* @__PURE__ */ new Set(); for (const item of group.fields.recipeIngredientItems) ingredientSet.add(this.parseIngredientItem(item)); ingredientMap.set(groupTitle, ingredientSet); } return ingredientMap; } }; //#endregion //#region src/scrapers/bbcgoodfood.ts var BBCGoodFood = class extends AbstractScraper { static host() { return "bbcgoodfood.com"; } extractors = { ingredients: this.ingredients.bind(this) }; ingredients(prevValue) { const headingSelector = ".recipe__ingredients h3"; const ingredientSelector = ".recipe__ingredients li"; if (isList(prevValue) && prevValue.size > 0) { const result = groupIngredients(this.$, prevValue, headingSelector, ingredientSelector); return result; } throw new Error("No ingredients found to group"); } }; //#endregion //#region src/scrapers/eatingwell.ts var EatingWell = class extends AbstractScraper { static host() { return "eatingwell.com"; } extractors = {}; }; //#endregion //#region src/scrapers/epicurious.ts var Epicurious = class extends AbstractScraper { static host() { return "epicurious.com"; } extractors = { author: this.author.bind(this) }; author() { const author = this.$("a[itemprop=\"author\"]").text().trim(); return author; } }; //#endregion //#region src/scrapers/nytimes.ts var NYTimes = class extends AbstractScraper { static host() { return "cooking.nytimes.com"; } extractors = { ingredients: this.ingredients.bind(this) }; ingredients(prevValue) { const headingSelector = "h3[class*=\"ingredientgroup_name\"]"; const ingredientSelector = "li[class*=\"ingredient\"]"; if (isList(prevValue) && prevValue.size > 0) { const result = groupIngredients(this.$, prevValue, headingSelector, ingredientSelector); return result; } throw new Error("No ingredients found to group"); } }; //#endregion //#region src/scrapers/seriouseats.ts var SeriousEats = class extends AbstractScraper { static host() { return "seriouseats.com"; } extractors = {}; }; //#endregion //#region src/scrapers/simplyrecipes.ts var SimplyRecipes = class extends AbstractScraper { static host() { return "simplyrecipes.com"; } extractors = { instructions: this.instructions.bind(this) }; /** * Scrape and normalize each step under * div.structured-project__steps > ol > li */ instructions() { const items = this.$("div.structured-project__steps ol li").toArray(); if (items.length === 0) return /* @__PURE__ */ new Set(); const steps = items.map((el) => { const $clone = this.$(el).clone(); $clone.find("img, picture, figure").remove(); return normalizeString($clone.text()); }).filter((text) => text.length > 0); return new Set(steps); } }; //#endregion //#region src/scrapers/_index.ts /** * A map of all scrapers. */ const scrapers = { [AllRecipes.host()]: AllRecipes, [AmericasTestKitchen.host()]: AmericasTestKitchen, [BBCGoodFood.host()]: BBCGoodFood, [EatingWell.host()]: EatingWell, [Epicurious.host()]: Epicurious, [SeriousEats.host()]: SeriousEats, [SimplyRecipes.host()]: SimplyRecipes, [NYTimes.host()]: NYTimes }; //#endregion //#region src/index.ts /** * Returns a scraper class for the given URL, if implemented. */ function getScraper(url) { const hostName = getHostName(url); if (scrapers[hostName]) return scrapers[hostName]; throw new Error(`The website '${hostName}' is not currently supported.\nIf you want to help add support, please open an issue!`); } //#endregion export { ExtractorPlugin, LogLevel, Logger, PostProcessorPlugin, getScraper, scrapers };