recipe-scrapers-js
Version:
A recipe scrapers library
1,329 lines (1,299 loc) • 44.7 kB
JavaScript
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(/&/g, "&").replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/"/g, "\"").replace(/'/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 };