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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user