diff --git a/package.yaml b/package.yaml index 2d44284..d21c6d4 100644 --- a/package.yaml +++ b/package.yaml @@ -28,6 +28,7 @@ ghc-options: dependencies: - base >= 4.7 && < 5 + - aeson - blaze-html - blaze-markup - bytestring diff --git a/roux-server.cabal b/roux-server.cabal index 53bd139..d08a592 100644 --- a/roux-server.cabal +++ b/roux-server.cabal @@ -18,6 +18,7 @@ library exposed-modules: Roux Roux.Html + Roux.NYTimes Roux.Parser Roux.RecipeIndex Roux.Server @@ -35,7 +36,8 @@ library TupleSections ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints build-depends: - base >=4.7 && <5 + aeson + , base >=4.7 && <5 , blaze-html , blaze-markup , bytestring @@ -66,7 +68,8 @@ executable check-recipes TupleSections ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints build-depends: - base >=4.7 && <5 + aeson + , base >=4.7 && <5 , blaze-html , blaze-markup , bytestring @@ -98,7 +101,8 @@ executable roux-server TupleSections ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N build-depends: - base >=4.7 && <5 + aeson + , base >=4.7 && <5 , blaze-html , blaze-markup , bytestring @@ -120,6 +124,7 @@ test-suite roux-server-test type: exitcode-stdio-1.0 main-is: Spec.hs other-modules: + Roux.NYTimesSpec Roux.ParserSpec Paths_roux_server autogen-modules: @@ -133,7 +138,8 @@ test-suite roux-server-test TupleSections ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints build-depends: - base >=4.7 && <5 + aeson + , base >=4.7 && <5 , blaze-html , blaze-markup , bytestring diff --git a/src/Roux/NYTimes.hs b/src/Roux/NYTimes.hs new file mode 100644 index 0000000..c6b9a51 --- /dev/null +++ b/src/Roux/NYTimes.hs @@ -0,0 +1,207 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- | Extractor for NYTimes Cooking recipe data from HTML pages. + +NYTimes recipe pages embed the recipe data in a @" + +-- --------------------------------------------------------------------------- +-- JSON parsing +-- --------------------------------------------------------------------------- + +parseNYTRecipe :: A.Value -> Maybe NYTRecipe +parseNYTRecipe val = do + obj <- asObject val + props <- asObject =<< lookupObj "props" obj + pageProps <- asObject =<< lookupObj "pageProps" props + recipe <- asObject =<< lookupObj "recipe" pageProps + + title <- lookupText "title" recipe + let url = lookupMaybeText "fullUrl" recipe + topnote = lookupMaybeText "topnote" recipe + totalTime = lookupMaybeText "totalTime" recipe + recipeYield = lookupMaybeText "recipeYield" recipe + + let ingredientGroups = case lookupMaybeArr "ingredients" recipe of + Just ings -> mapMaybe parseIngredientGroup ings + Nothing -> [] + stepGroups = case lookupMaybeArr "steps" recipe of + Just steps -> mapMaybe parseStepGroup steps + Nothing -> [] + + Just + NYTRecipe + { nytTitle = title + , nytUrl = url + , nytTotalTime = totalTime + , nytRecipeYield = recipeYield + , nytTopnote = topnote + , nytIngredientGroups = ingredientGroups + , nytStepGroups = stepGroups + } + +parseIngredientGroup :: Value -> Maybe NYTIngredientGroup +parseIngredientGroup val = do + obj <- asObject val + let name = fromMaybe "" (lookupMaybeText "name" obj) + items <- lookupArr "ingredients" obj + Just + NYTIngredientGroup + { nytGroupName = name + , nytIngredients = mapMaybe parseIngredientItem items + } + +parseIngredientItem :: Value -> Maybe NYTIngredientItem +parseIngredientItem val = do + obj <- asObject val + text <- lookupText "text" obj + let quantity = fromMaybe "" (lookupMaybeText "quantity" obj) + Just + NYTIngredientItem + { nytIngredientText = text + , nytIngredientQuantity = quantity + } + +parseStepGroup :: Value -> Maybe NYTStepGroup +parseStepGroup val = do + obj <- asObject val + let name = fromMaybe "" (lookupMaybeText "name" obj) + items <- lookupArr "steps" obj + Just + NYTStepGroup + { nytStepGroupName = name + , nytSteps = mapMaybe parseStepItem items + } + +parseStepItem :: Value -> Maybe NYTStepItem +parseStepItem val = do + obj <- asObject val + num <- lookupInt "number" obj + desc <- lookupText "description" obj + Just + NYTStepItem + { nytStepNumber = num + , nytStepDescription = desc + } + +-- --------------------------------------------------------------------------- +-- Helpers for navigating Aeson values +-- --------------------------------------------------------------------------- + +asObject :: Value -> Maybe A.Object +asObject (A.Object o) = Just o +asObject _ = Nothing + +-- | Look up a required object value. +lookupObj :: Key -> A.Object -> Maybe Value +lookupObj = KM.lookup + +-- | Look up a required text field. +lookupText :: Key -> A.Object -> Maybe Text +lookupText k obj = case KM.lookup k obj of + Just (A.String t) -> Just t + _ -> Nothing + +-- | Look up an optional text field (missing or null returns Nothing). +lookupMaybeText :: Key -> A.Object -> Maybe Text +lookupMaybeText k obj = case KM.lookup k obj of + Just (A.String t) -> Just t + _ -> Nothing + +-- | Look up a required int field. +lookupInt :: Key -> A.Object -> Maybe Int +lookupInt k obj = case KM.lookup k obj of + Just (A.Number n) -> Just (floor n) + _ -> Nothing + +-- | Look up a required array field. +lookupArr :: Key -> A.Object -> Maybe [Value] +lookupArr k obj = case KM.lookup k obj of + Just (A.Array arr) -> Just (foldr (:) [] arr) + _ -> Nothing + +-- | Look up an optional array field. +lookupMaybeArr :: Key -> A.Object -> Maybe [Value] +lookupMaybeArr k obj = case KM.lookup k obj of + Just (A.Array arr) -> Just (foldr (:) [] arr) + _ -> Nothing + +fromMaybe :: a -> Maybe a -> a +fromMaybe def Nothing = def +fromMaybe _ (Just x) = x diff --git a/test-data/carrot-risotto.html b/test-data/carrot-risotto.html new file mode 100644 index 0000000..c35c40a --- /dev/null +++ b/test-data/carrot-risotto.html @@ -0,0 +1,70 @@ +Carrot Risotto With Chile Crisp Recipe

Carrot Risotto With Chile Crisp

Updated April 3, 2023

Carrot Risotto With Chile Crisp
David Malosh for The New York Times. Food Stylist: Simon Andrews.
Total Time
30 minutes
Rating
5(2,295)
Comments
Read comments

Simple yet surprising, this pantry risotto is a study in contrasts: Luscious, creamy risotto is topped with caramelized carrots that are roasted with spicy chile crisp while you make the rice. The risotto base — a classic approach using shallots, garlic and white wine — is the foundation for a cheap though lush meal that can be cobbled together with pantry ingredients. This one is subtly sweetened with freshly grated carrots, then topped with a pile of smoky roasted carrots. Don’t underestimate the versatility of chile crisp: This tingly, crunchy condiment can animate any number of rich dishes like risotto, pizza and macaroni and cheese with a robust dose of heat. In recipes, as in life, opposites attract.

  • or to save this recipe.

  • Subscriber benefit: Give recipes to anyone

    As a subscriber, you have 10 gift recipes to give each month. Anyone can view them - even nonsubscribers. Learn more.

  • Share this recipe

  • Print this recipe

    or to print this recipe.

Advertisement


Ingredients

Yield:4 servings
  • pounds carrots, peeled
  • 2tablespoons store-bought or homemade chile crisp, plus more for serving
  • Kosher salt (such as Diamond Crystal) and black pepper
  • 5cups chicken or vegetable stock
  • 4tablespoons unsalted butter
  • 1large shallot, minced
  • 3garlic cloves, finely chopped
  • ½teaspoon ground coriander
  • ½cup dry white wine
  • cups/10 ounces arborio rice
  • ounces finely grated Parmesan (about 1¼ cups, packed), plus more for serving (optional)
Ingredient Substitution Guide
Nutritional analysis per serving (4 servings)

692 calories; 21 grams fat; 11 grams saturated fat; 0 grams trans fat; 7 grams monounsaturated fat; 2 grams polyunsaturated fat; 100 grams carbohydrates; 11 grams dietary fiber; 19 grams sugars; 22 grams protein; 1660 milligrams sodium

Note: The information shown is Edamam’s estimate based on available ingredients and preparation. It should not be considered a substitute for a professional nutritionist’s advice.

Powered by

Preparation

  1. Step 1

    Heat the oven to 400 degrees. Prep the carrots: If you’ve got a food processor, roughly chop ¾ pound carrots. Add them to the bowl of your food processor; pulse until they form very small, confetti-like flecks no larger than ¼-inch big (you should have about 2 cups); set aside. (You can also coarsely grate the carrots using a box grater.) Slice the remaining 1½ pounds carrots on a sharp diagonal about ¼-inch thick; transfer to a baking sheet. Toss the sliced carrots with 2 tablespoons chile crisp. (If your chile crisp is predominantly crunchy bits, you can thin it with a little canola or vegetable oil, if needed.) Season the carrots generously with salt and pepper and bake until tender, about 15 minutes, stirring halfway through.

  2. Step 2

    Add the stock to a lidded saucepan (any size that will hold it will do); cover and warm over low.

  3. Step 3

    In a medium saucepan, melt 2 tablespoons butter over medium-high heat. Add the uncooked carrots, shallot, garlic and coriander; season generously with salt and pepper. Cook, stirring frequently, until very fragrant, 2 minutes.

  4. Step 4

    Add the wine to the carrot mixture and cook, stirring occasionally, until mostly evaporated, about 3 minutes.

  5. Step 5

    Stir the rice into the carrot mixture, reduce the heat to medium and cook, stirring, 2 minutes. Add 1 cup warmed stock and cook, stirring frequently, until the liquid is almost absorbed, about 3 minutes. Repeat 4 more times, adding warm liquid and stirring until absorbed.

  6. Step 6

    Once the rice is tender and creamy and all the stock has been absorbed, add the cheese and the remaining 2 tablespoons butter; stir vigorously to combine until the risotto feels silky, creamy, luxurious. Season the risotto to taste with salt and pepper.

  7. Step 7

    Divide risotto among shallow bowls or plates and top with the roasted carrots. Serve with additional chile crisp, for drizzling on top.

Private Notes

Leave a Private Comment on this recipe and see it here.

Ratings

5 out of 5
2,295 user ratings
Your rating

or to rate this recipe.

Have you cooked this?

or to mark this recipe as cooked.

Comments

This recipe tastes fantastic, and is not difficult to make. However, the only way this recipe takes 30 minutes is if you have an entire kitchen crew working to make it.

Adding this to my "meals to impress a date with" list. For protein I added a drained can of cannellini beans at the same time as the rice; it worked remarkably well! They melted right into the risotto and added to the creaminess.

This sounded interesting to me yet easy and it lived up to my expectations. It was delicious. I incorporated some grated turmeric root into the grated carrot and used a ginger and turmeric broth made by Kettle and Fire. The flavors all melded together and the roasted carrots were delicious. A winner!

I’ve made this twice (adding extra shallot because I’m obsessed with it) and I am blown away with this meal. It’s so good, so easy to perfect, and would impress even your fanciest eaters. Don’t sleep on this!

It's been 3 days and 3 nights since I made the risotto (It's spectacular by the way). The oven is still on. + +I'm looking at it and it looks back. Neither of us knows what is our purpose in this reccepy or this world.

Really good and as an amateur that's trying to be better in the kitchen it was an impressive dish to make. Took a LOT more work than anticipated like others have said. I would cook it again as a side dish for a holiday.

Private comments are only visible to you.

or to save this recipe.


\ No newline at end of file diff --git a/test-data/fried-rice.html b/test-data/fried-rice.html new file mode 100644 index 0000000..7405715 --- /dev/null +++ b/test-data/fried-rice.html @@ -0,0 +1,76 @@ +Fried Rice Recipe

Fried Rice

Fried Rice
Meredith Heuer for The New York Times
Total Time
20 minutes
Rating
5(5,536)
Comments
Read comments

Here's a brilliant and addictive way to use up leftovers that comes together in about 20 minutes. It can accommodate practically any vegetable languishing in your refrigerator, and adding leftover cooked chicken, pork, beef, shrimp or tofu isn't a bad idea either.

Featured in: Fried Rice

  • or to save this recipe.

  • Subscriber benefit: Give recipes to anyone

    As a subscriber, you have 10 gift recipes to give each month. Anyone can view them - even nonsubscribers. Learn more.

  • Share this recipe

  • Print this recipe

    or to print this recipe.

Advertisement


Ingredients

Yield:4 to 6 servings
  • 3tablespoons neutral oil, like canola or grapeseed
  • 1medium onion, peeled and roughly chopped
  • 1red bell pepper, stemmed, cored and roughly chopped
  • 2carrots, peeled and finely minced
  • 1tablespoon minced garlic, or to taste
  • 1cup peas (defrost if frozen)
  • 1tablespoon minced ginger, or to taste
  • 3 to 4cups cooked white rice, cooled
  • 2eggs, lightly beaten
  • ¼cup Shaoxing wine, or water
  • 2tablespoons soy sauce
  • 1tablespoon sesame oil
  • Salt and freshly ground black pepper to taste
  • ½cup minced cilantro or scallions
Ingredient Substitution Guide
Nutritional analysis per serving (6 servings)

300 calories; 11 grams fat; 1 gram saturated fat; 0 grams trans fat; 6 grams monounsaturated fat; 3 grams polyunsaturated fat; 41 grams carbohydrates; 3 grams dietary fiber; 4 grams sugars; 7 grams protein; 545 milligrams sodium

Note: The information shown is Edamam’s estimate based on available ingredients and preparation. It should not be considered a substitute for a professional nutritionist’s advice.

Powered by

Preparation

  1. Step 1

    Put 1 tablespoon of the oil in a wok or a large skillet, and turn heat to high. When it begins to shimmer, add onion, pepper and carrots and cook, stirring occasionally, until softened and beginning to brown, 5 to 10 minutes. With a slotted spoon, remove vegetables to a bowl.

  2. Step 2

    Drain peas if necessary, and add them to skillet; cook, shaking pan, for about a minute, or until hot. Remove them to the bowl.

  3. Step 3

    Put remaining oil in the skillet, followed by garlic and ginger. When the mixture is fragrant, about 15 seconds later, add the rice, breaking up clumps with a spoon as you go along and tossing it with oil. When the rice is well coated, make a well in the center and break the eggs into it. Scramble these, then stir into the rice.

  4. Step 4

    Return vegetables to the skillet and stir to integrate. Add wine or water and cook, stirring, for approximately 1 minute. Add soy sauce and sesame oil, then taste and add salt and pepper if necessary. Turn off heat, stir in the cilantro and serve.

Private Notes

Leave a Private Comment on this recipe and see it here.

Ratings

5 out of 5
5,536 user ratings
Your rating

or to rate this recipe.

Have you cooked this?

or to mark this recipe as cooked.

Comments

I usually make a big batch of basmati rice and keep it in the fridge for the week. Fried rice is my favorite. Very comforting. My ideas for this dish is to first saute the garlic and ginger for a few seconds. Then add the vegetables on high heat. I really like baby bok choy, snow peas, carrots, broccoli and sweet peppers. Stir fry the vegetables with low salt soy sauce, toasted sesame oil and a pinch of allspice. Add the cold rice and drizzle in small amount of hoisin sauce. Top with scallions.

Don't forget a drizzle of fish sauce while cooking the fried rice! It's what gives it that take out fried rice flavor!

In lieu of Shaoxing Wine you can use a dry sherry.

Yum! Make the rice a day in advance and let it dry out uncovered in the refrigerator overnight. You'll be happy with the results. Great recipe!

Somebody beat me to it, but let me affirm: fish sauce during cooking is the secret ingredient in restaurant fried rice. Disregard the smell as it cooks. That will go, and the flavor you hoped for will remain.

I make ad-hoc fried rice often, and this did not match the texture nor taste of what I usually achieve. + +1. Eliminate peas and carrots. This isn’t a British side dish +2. Add a heart green veggie like broccoli. Par-steam to slightly soften, but finish it on medium/high heat to crisp and brown it +3 Skip the white wine. This isn’t pasta. The wine adds wetness you don’t want, and the flavor is too subtle to stand up to Asian flavors +4. Add about 1tbsp of fish sauce near the end. You can also splash soy sauce, seasoned rice wine vinegar, black vinegar, mirin, or other things, but don’t allow the dish to get wet. If you have a puddle in the middle of the wok, you’ve overdone it. But experiment with flavors, this is where it becomes your dish. +5. If you want protein, cook it in advance in the wok and re-incorporate it after step 4. + +Enjoy! This dish should be as easy as making a pasta, but with none of the same flavors

Private comments are only visible to you.

or to save this recipe.


\ No newline at end of file diff --git a/test/Roux/NYTimesSpec.hs b/test/Roux/NYTimesSpec.hs new file mode 100644 index 0000000..10f7a5f --- /dev/null +++ b/test/Roux/NYTimesSpec.hs @@ -0,0 +1,76 @@ +module Roux.NYTimesSpec (spec) where + +import qualified Data.Text.IO as TIO +import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy) + +import Roux.NYTimes + +spec :: Spec +spec = do + describe "extractNextData" $ do + it "extracts JSON from fried-rice.html" $ do + html <- TIO.readFile "test-data/fried-rice.html" + let result = extractNextData html + result `shouldSatisfy` isJust + + it "extracts JSON from carrot-risotto.html" $ do + html <- TIO.readFile "test-data/carrot-risotto.html" + let result = extractNextData html + result `shouldSatisfy` isJust + + describe "parseNYTRecipe" $ do + it "parses fried-rice.html recipe correctly" $ do + html <- TIO.readFile "test-data/fried-rice.html" + let result = do + val <- extractNextData html + parseNYTRecipe val + case result of + Nothing -> error "failed to parse fried-rice recipe" + Just recipe -> do + nytTitle recipe `shouldBe` "Fried Rice" + nytUrl recipe `shouldBe` Just "https://cooking.nytimes.com/recipes/12177-fried-rice" + nytTotalTime recipe `shouldBe` Just "20 minutes" + nytRecipeYield recipe `shouldBe` Just "4 to 6 servings" + nytTopnote recipe `shouldSatisfy` isJust + length (nytIngredientGroups recipe) `shouldBe` 1 + case nytIngredientGroups recipe of + [group] -> do + length (nytIngredients group) `shouldBe` 14 + case nytIngredients group of + (firstIng : _) -> do + nytIngredientText firstIng `shouldBe` "tablespoons neutral oil, like canola or grapeseed" + nytIngredientQuantity firstIng `shouldBe` "3" + [] -> error "empty ingredient group" + _ -> error "expected 1 ingredient group" + case nytStepGroups recipe of + [stepGroup] -> do + length (nytSteps stepGroup) `shouldBe` 4 + case nytSteps stepGroup of + (firstStep : _) -> nytStepNumber firstStep `shouldBe` 1 + [] -> error "empty step group" + _ -> error "expected 1 step group" + + it "parses carrot-risotto.html recipe correctly" $ do + html <- TIO.readFile "test-data/carrot-risotto.html" + let result = do + val <- extractNextData html + parseNYTRecipe val + case result of + Nothing -> error "failed to parse carrot-risotto recipe" + Just recipe -> do + nytTitle recipe `shouldBe` "Carrot Risotto With Chile Crisp" + nytUrl recipe `shouldBe` Just "https://cooking.nytimes.com/recipes/1024086-carrot-risotto-with-chile-crisp" + nytTotalTime recipe `shouldBe` Just "30 minutes" + nytRecipeYield recipe `shouldBe` Just "4 servings" + length (nytIngredientGroups recipe) `shouldBe` 1 + case nytIngredientGroups recipe of + [group] -> length (nytIngredients group) `shouldBe` 11 + _ -> error "expected 1 ingredient group" + case nytStepGroups recipe of + [stepGroup] -> length (nytSteps stepGroup) `shouldBe` 7 + _ -> error "expected 1 step group" + +-- | Check if a Maybe value is Just. +isJust :: Maybe a -> Bool +isJust (Just _) = True +isJust Nothing = False diff --git a/test/Spec.hs b/test/Spec.hs index 6676ac0..817a998 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -3,13 +3,16 @@ module Main (main) where import Test.Tasty (defaultMain, testGroup) import Test.Tasty.Hspec (testSpec) +import qualified Roux.NYTimesSpec import qualified Roux.ParserSpec main :: IO () main = do parserTests <- testSpec "Parser" Roux.ParserSpec.spec + nytTests <- testSpec "NYTimes" Roux.NYTimesSpec.spec defaultMain $ testGroup "roux-server" [ parserTests + , nytTests ]