From f8114af4a981373f8552b2b5398436b782c9f0e2 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 16:03:43 -0400 Subject: [PATCH] Add nytToCooklang conversion from NYTRecipe to Data.CookLang.Recipe - Add nytToCooklang function to Roux.NYTimes - buildMetadata: maps title, URL, description, time, servings - buildSections: creates sections for ingredient groups then step groups - ingredientGroupToSection: converts NYT ingredient items to Cooklang step items with @ingredient references - stepGroupToSection: converts method steps into Cooklang steps - Add parseDuration, parseServings, parseNYTQuantity helpers - Add extractIngredientName heuristic for core ingredient extraction - Add 2 conversion tests verifying both example recipes - All 32 tests passing, hlint clean --- src/Roux/NYTimes.hs | 176 +++++++++++++++++++++++++++++++++++++++ test/Roux/NYTimesSpec.hs | 29 +++++++ 2 files changed, 205 insertions(+) diff --git a/src/Roux/NYTimes.hs b/src/Roux/NYTimes.hs index c6b9a51..c893860 100644 --- a/src/Roux/NYTimes.hs +++ b/src/Roux/NYTimes.hs @@ -14,17 +14,21 @@ module Roux.NYTimes ( NYTStepItem (..), extractNextData, parseNYTRecipe, + nytToCooklang, ) where import Data.Aeson (Value (..)) import qualified Data.Aeson as A import Data.Aeson.Key (Key) import qualified Data.Aeson.KeyMap as KM +import Data.List.NonEmpty (NonEmpty ((:|))) import Data.Maybe (mapMaybe) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE +import Data.CookLang + -- --------------------------------------------------------------------------- -- Intermediate data types -- --------------------------------------------------------------------------- @@ -205,3 +209,175 @@ lookupMaybeArr k obj = case KM.lookup k obj of fromMaybe :: a -> Maybe a -> a fromMaybe def Nothing = def fromMaybe _ (Just x) = x + +-- --------------------------------------------------------------------------- +-- Conversion to Data.CookLang +-- --------------------------------------------------------------------------- + +-- | Convert an 'NYTRecipe' into a 'Data.CookLang.Recipe'. +nytToCooklang :: NYTRecipe -> Recipe +nytToCooklang nyt = + Recipe + { recipeMetadata = buildMetadata nyt + , recipeSections = buildSections nyt + } + +-- | Build metadata from NYT fields. +buildMetadata :: NYTRecipe -> Metadata +buildMetadata nyt = + emptyMetadata + { metaTitle = Just (nytTitle nyt) + , metaSource = nytUrl nyt + , metaDescription = nytTopnote nyt + , metaTotalTime = parseDuration =<< nytTotalTime nyt + , metaServings = parseServings =<< nytRecipeYield nyt + } + +-- | Build sections: ingredient groups first, then step groups. +buildSections :: NYTRecipe -> NonEmpty Section +buildSections nyt = + let ingSections = map ingredientGroupToSection (nytIngredientGroups nyt) + stepSections = map stepGroupToSection (nytStepGroups nyt) + allSections = ingSections ++ stepSections + in case allSections of + (s : ss) -> s :| ss + [] -> Section Nothing (SecStep (Step []) :| []) :| [] + +-- | Convert an ingredient group to a section with an ingredient-list step. +ingredientGroupToSection :: NYTIngredientGroup -> Section +ingredientGroupToSection group = + let name = if T.null (nytGroupName group) then Nothing else Just (nytGroupName group) + items = nytIngredients group + step = Step (concatMap ingredientToStepItems items) + in Section + { sectionName = name + , sectionBody = SecStep step :| [] + } + +-- | Convert a single NYT ingredient into Cooklang step items. +-- Produces something like: @ingredient{quantity} — rest of description +ingredientToStepItems :: NYTIngredientItem -> [StepItem] +ingredientToStepItems item = + let quantity = nytIngredientQuantity item + fullText = nytIngredientText item + -- Try to extract a name from the text by taking words that look + -- like the actual ingredient (after quantity/unit words) + name = extractIngredientName fullText + qty = parseNYTQuantity quantity + in if T.null quantity + then [ StepText ", ", StepIngredient (Ingredient (simplifyText fullText) Nothing Nothing) + ] + else + [ StepText ", " + , StepIngredient (Ingredient name qty Nothing) + , StepText (" " <> simplifyText (dropLeadingWord fullText)) + ] + +-- | Convert a step group to a section. +stepGroupToSection :: NYTStepGroup -> Section +stepGroupToSection group = + let name = if T.null (nytStepGroupName group) then Nothing else Just (nytStepGroupName group) + steps = map stepItemToStep (nytSteps group) + in Section + { sectionName = name + , sectionBody = case steps of + (s : ss) -> SecStep s :| map SecStep ss + [] -> SecStep (Step []) :| [] + } + +-- | Convert a single step item to a Cooklang step with plain text. +stepItemToStep :: NYTStepItem -> Step +stepItemToStep si = + Step [StepText (nytStepDescription si)] + +-- --------------------------------------------------------------------------- +-- Parsing helpers +-- --------------------------------------------------------------------------- + +-- | Parse a duration string like @"20 minutes"@ into a 'Duration'. +parseDuration :: Text -> Maybe Duration +parseDuration t = + case T.words t of + (numStr : unitWords) -> do + n <- readMaybe (T.unpack numStr) + let unit = T.intercalate " " unitWords + Just (Duration (toRational (n :: Int)) (Just unit)) + _ -> Nothing + +-- | Parse a servings string like @"4 to 6 servings"@ into @(Int, Maybe Text)@. +parseServings :: Text -> Maybe (Int, Maybe Text) +parseServings t = + case T.words t of + (numStr : rest) -> do + n <- readMaybe (T.unpack numStr) + let unit = case rest of + (_ : _) -> Just (T.intercalate " " rest) + [] -> Nothing + Just (n, unit) + _ -> Nothing + +-- | Parse a NYT quantity string into a Cooklang 'Quantity'. +parseNYTQuantity :: Text -> Maybe Quantity +parseNYTQuantity t + | T.null t = Nothing + | otherwise = + let cleaned = T.strip t + -- Handle ranges like "3 to 4" -> take first number + (amountStr, _rest) = breakOn " " cleaned + in case readMaybe (T.unpack amountStr) of + Just n -> + Just + Quantity + { quantityAmount = toRational (n :: Double) + , quantityUnit = Nothing + , quantityFixed = False + } + Nothing -> Nothing + +-- | Try to extract the core ingredient name from a descriptive text. +-- E.g. @"tablespoons neutral oil, like canola or grapeseed"@ -> @"oil"@ +-- This is a heuristic; for complex cases the full text is used. +extractIngredientName :: Text -> Text +extractIngredientName t = + let cleaned = simplifyText t + toks = T.words cleaned + -- Filter out common unit/quantity words to find the core ingredient + skipWords = + [ "tablespoons", "tablespoon", "teaspoons", "teaspoon" + , "cups", "cup", "ounces", "ounce", "pounds", "pound" + , "grams", "gram", "cloves", "clove", "slices", "slice" + , "large", "medium", "small", "fresh", "dried", "minced" + , "chopped", "diced", "peeled", "grated", "crushed" + , "ground", "whole", "skinless", "boneless" + ] + meaningful = dropWhile (`elem` skipWords) toks + in case meaningful of + (w : _) -> w + [] -> cleaned + +-- | Simplify ingredient text by removing parentheticals and HTML tags. +simplifyText :: Text -> Text +simplifyText = + T.strip + . T.replace " " " " + . T.filter (/= '\n') + +-- | Drop the first word from text (used after extracting quantity). +dropLeadingWord :: Text -> Text +dropLeadingWord t = + case T.break (== ' ') t of + (_, rest) -> T.strip rest + +-- | Break on the first space. +breakOn :: Text -> Text -> (Text, Text) +breakOn delim t = + let parts = T.splitOn delim t + in case parts of + (a : b : _) -> (a, b) + _ -> (t, T.empty) + +-- | Simple read wrapper. +readMaybe :: Read a => String -> Maybe a +readMaybe s = case reads s of + [(x, "")] -> Just x + _ -> Nothing diff --git a/test/Roux/NYTimesSpec.hs b/test/Roux/NYTimesSpec.hs index 10f7a5f..34f29f1 100644 --- a/test/Roux/NYTimesSpec.hs +++ b/test/Roux/NYTimesSpec.hs @@ -4,6 +4,7 @@ import qualified Data.Text.IO as TIO import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy) import Roux.NYTimes +import Data.CookLang spec :: Spec spec = do @@ -70,6 +71,34 @@ spec = do [stepGroup] -> length (nytSteps stepGroup) `shouldBe` 7 _ -> error "expected 1 step group" + describe "nytToCooklang" $ do + it "converts fried-rice NYTRecipe to Cooklang Recipe" $ do + html <- TIO.readFile "test-data/fried-rice.html" + let result = do + val <- extractNextData html + nyt <- parseNYTRecipe val + pure (nytToCooklang nyt) + case result of + Nothing -> error "failed to parse/convert fried-rice" + Just recipe -> do + metaTitle (recipeMetadata recipe) `shouldBe` Just "Fried Rice" + metaSource (recipeMetadata recipe) `shouldBe` Just "https://cooking.nytimes.com/recipes/12177-fried-rice" + metaTotalTime (recipeMetadata recipe) `shouldBe` Just (Duration 20 (Just "minutes")) + length (recipeSections recipe) `shouldSatisfy` (> 0) + + it "converts carrot-risotto NYTRecipe to Cooklang Recipe" $ do + html <- TIO.readFile "test-data/carrot-risotto.html" + let result = do + val <- extractNextData html + nyt <- parseNYTRecipe val + pure (nytToCooklang nyt) + case result of + Nothing -> error "failed to parse/convert carrot-risotto" + Just recipe -> do + metaTitle (recipeMetadata recipe) `shouldBe` Just "Carrot Risotto With Chile Crisp" + metaTotalTime (recipeMetadata recipe) `shouldBe` Just (Duration 30 (Just "minutes")) + length (recipeSections recipe) `shouldSatisfy` (> 0) + -- | Check if a Maybe value is Just. isJust :: Maybe a -> Bool isJust (Just _) = True