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
This commit is contained in:
2026-05-19 16:03:43 -04:00
parent 4055bf62c1
commit f8114af4a9
2 changed files with 205 additions and 0 deletions
+176
View File
@@ -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
+29
View File
@@ -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