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 (..),
|
NYTStepItem (..),
|
||||||
extractNextData,
|
extractNextData,
|
||||||
parseNYTRecipe,
|
parseNYTRecipe,
|
||||||
|
nytToCooklang,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Aeson (Value (..))
|
import Data.Aeson (Value (..))
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
import Data.Aeson.Key (Key)
|
import Data.Aeson.Key (Key)
|
||||||
import qualified Data.Aeson.KeyMap as KM
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
|
import Data.List.NonEmpty (NonEmpty ((:|)))
|
||||||
import Data.Maybe (mapMaybe)
|
import Data.Maybe (mapMaybe)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as TE
|
import qualified Data.Text.Encoding as TE
|
||||||
|
|
||||||
|
import Data.CookLang
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Intermediate data types
|
-- Intermediate data types
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -205,3 +209,175 @@ lookupMaybeArr k obj = case KM.lookup k obj of
|
|||||||
fromMaybe :: a -> Maybe a -> a
|
fromMaybe :: a -> Maybe a -> a
|
||||||
fromMaybe def Nothing = def
|
fromMaybe def Nothing = def
|
||||||
fromMaybe _ (Just x) = x
|
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 Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)
|
||||||
|
|
||||||
import Roux.NYTimes
|
import Roux.NYTimes
|
||||||
|
import Data.CookLang
|
||||||
|
|
||||||
spec :: Spec
|
spec :: Spec
|
||||||
spec = do
|
spec = do
|
||||||
@@ -70,6 +71,34 @@ spec = do
|
|||||||
[stepGroup] -> length (nytSteps stepGroup) `shouldBe` 7
|
[stepGroup] -> length (nytSteps stepGroup) `shouldBe` 7
|
||||||
_ -> error "expected 1 step group"
|
_ -> 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.
|
-- | Check if a Maybe value is Just.
|
||||||
isJust :: Maybe a -> Bool
|
isJust :: Maybe a -> Bool
|
||||||
isJust (Just _) = True
|
isJust (Just _) = True
|
||||||
|
|||||||
Reference in New Issue
Block a user