-
Notifications
You must be signed in to change notification settings - Fork 15
/
Main.hs
200 lines (176 loc) · 7.58 KB
/
Main.hs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
module Main where
import Control.Monad
import Data.ByteString.Lazy (ByteString)
import Data.ByteString.Lazy qualified as L
import Data.FileEmbed (embedStringFile)
import Data.Maybe
import Data.Time (defaultTimeLocale, formatTime, getCurrentTime, getCurrentTimeZone, utcToLocalTime)
import Database.SQLite.Simple qualified as SQL
import GHC.Generics
import Prices (Item (..), allItemsByStore)
import System.Directory (createDirectory, doesDirectoryExist, removeDirectoryRecursive)
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.IO
import Text.Blaze.Html.Renderer.Utf8 (renderHtml)
import Text.Blaze.Html5 ((!))
import Text.Blaze.Html5 qualified as H
import Text.Blaze.Html5.Attributes qualified as A
import Text.Blaze.Internal qualified as A
import Text.Read (readMaybe)
main :: IO ()
main = getArgs >>= handleArgs
help :: String
help =
"Usage:\n\
\ traderjoes fetch\n\
\ traderjoes gen\
\"
printlog :: String -> IO ()
printlog = hPutStrLn stderr
handleArgs :: [String] -> IO ()
handleArgs ["gen"] = do
conn <- openDB
changes <- priceChanges conn
allitems <- latestPrices conn
SQL.close conn
ts <- showTime
let html = renderPage $ pageBody changes allitems ts
setupCleanDirectory "site"
printlog "writing to ./site"
L.writeFile "site/index.html" html
handleArgs ["fetch"] = do
conn <- openDB
printlog "running..."
let stores =
[ "701" -- Chicago South Loop
, "31" -- Los Angeles
, "546" -- NYC East Village
, "452" -- Austin Seaholm
]
mapM_ (scrapeStore conn) stores
printlog "done"
changeCount <- SQL.totalChanges conn
printlog $ "changed rows: " <> show changeCount
SQL.close conn
handleArgs _ = printlog help >> exitFailure
-- | Fetch all items for the store and insert into the given database.
scrapeStore :: SQL.Connection -> String -> IO ()
scrapeStore conn store = do
printlog $ "fetching items (store " <> store <> ")..."
items <- allItemsByStore store
printlog $ "fetched items (store " <> store <> "): " <> (show . length $ items)
printlog $ "inserting into database (store " <> store <> ")..."
mapM_ (insert conn store) items
-- | Generate the home page html body.
pageBody :: [PriceChange] -> [DBItem] -> String -> H.Html
pageBody changes items timestamp = do
H.i . H.toMarkup $ "Last updated: " ++ timestamp
H.br
H.a ! A.class_ "underline" ! A.href "https://github.com/cmoog/traderjoes" ! A.target "_blank" $ "Source code"
H.br
H.a ! A.class_ "underline" ! A.href "https://data.traderjoesprices.com/dump.csv" ! download "traderjoes-dump.csv" $ "Download full history (.csv)"
H.br
H.br
H.strong ! A.style "font-size: 1.15em; font-family: serif" $ do
H.i "Disclaimer: This website is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Trader Joe's, or any of its subsidiaries or its affiliates. All prices are sourced from Trader Joe's South Loop in Chicago, IL (store code 701). There may be regional price differences from those listed on this site. This website may include discontinued or unavailable products."
H.br
H.h1 "(Unofficial) Trader Joe's Price Tracking"
H.form ! A.action "signup" ! A.class_ "signup-form" ! A.role "form" $ do
H.label ! A.for "email" $ "Sign up for a weekly email of price changes."
H.input ! A.required "" ! A.name "email" ! A.type_ "email" ! A.class_ "formInput input-lg" ! A.placeholder "[email protected]"
H.button ! A.type_ "submit" ! A.class_ "btn primary" ! A.title "Email address" $ "Sign Up"
H.h2 "Price Changes"
H.table ! A.class_ "table table-striped table-gray" $ do
H.thead . H.tr . H.toMarkup $ H.th <$> ["Date Changed", "Item Name", "Old Price", "New Price"]
H.tbody . H.toMarkup $ displayPriceChange <$> changes
H.h2 "All Items"
H.table ! A.class_ "table table-striped table-gray" $ do
H.thead . H.tr . H.toMarkup $ H.th <$> ["Item Name", "Retail Price"]
H.tbody . H.toMarkup $ displayDBItem <$> items
-- | Render the given page body with html head/styles/meta.
renderPage :: (H.ToMarkup a) => a -> ByteString
renderPage page = renderHtml $ H.html $ do
H.head $ do
H.meta ! A.charset "UTF-8"
H.meta ! A.name "viewport" ! A.content "width=device-width, initial-scale=1.0"
H.meta ! A.name "description" ! A.content "Daily Tracking of Trader Joe's Price Changes"
H.title "Trader Joe's Prices"
H.body $ do
H.style $(embedStringFile "./style.css")
H.toMarkup page
-- | Display item as a table row.
displayDBItem :: DBItem -> H.Html
displayDBItem (DBItem{ditem_title, dretail_price, dsku}) = H.tr $ do
H.td $ H.a ! A.href (productUrl dsku) ! A.target "_blank" $ H.toHtml ditem_title
H.td $ H.toHtml dretail_price
-- | Display the price change as a table row.
displayPriceChange :: PriceChange -> H.Html
displayPriceChange (PriceChange{pitem_title, pbefore_price, pafter_price, pafter_date, psku}) = H.tr $ do
H.td $ H.toHtml pafter_date
H.td $ H.a ! A.href (productUrl psku) ! A.target "_blank" $ H.toHtml pitem_title
H.td $ H.toHtml pbefore_price
H.td ! A.class_ (H.toValue $ priceChangeClass (pbefore_price, pafter_price)) $ H.toHtml pafter_price
-- | Color the price change table cell based on whether the price increased or decreased.
priceChangeClass :: (String, String) -> String
priceChangeClass (before, after) = fromMaybe "" $ do
beforeNum <- readMaybe before :: Maybe Float
afterNum <- readMaybe after :: Maybe Float
return $ if beforeNum > afterNum then "green" else "red"
-- | URL to the product detail page by `sku`.
productUrl :: String -> H.AttributeValue
productUrl sku = H.toValue $ "https://traderjoes.com/home/products/pdp/" ++ sku
data DBItem = DBItem
{ dsku :: String
, ditem_title :: String
, dretail_price :: String
, dinserted_at :: String
, dstore_code :: String
}
deriving (Generic, Show)
instance SQL.FromRow DBItem
instance SQL.ToRow DBItem
-- | Fetch the latest seen price for each `sku`.
latestPrices :: SQL.Connection -> IO [DBItem]
latestPrices conn = SQL.query_ conn $(embedStringFile "./sql/latest-prices.sql")
data PriceChange = PriceChange
{ psku :: String
, pitem_title :: String
, pbefore_price :: String
, pafter_price :: String
, pbefore_date :: String
, pafter_date :: String
, pstore_code :: String
}
deriving (Generic, Show)
instance SQL.FromRow PriceChange
-- | Each change in item `retail_price` partitioned by `sku` and `store_code`.
priceChanges :: SQL.Connection -> IO [PriceChange]
priceChanges conn = SQL.query_ conn $(embedStringFile "./sql/price-changes.sql")
openDB :: IO SQL.Connection
openDB = do
conn <- SQL.open "traderjoes.db"
SQL.execute_ conn $(embedStringFile "./sql/schema.sql")
return conn
insert :: SQL.Connection -> String -> Item -> IO ()
insert conn store (Item{sku, item_title, retail_price, availability}) =
SQL.execute
conn
"INSERT INTO items (sku, retail_price, item_title, store_code, availability, inserted_at) VALUES (?, ?, ?, ?, ?, DATETIME('now'))"
(sku, retail_price, item_title, store, availability)
-- | Show a timestamp in the current system timezone.
showTime :: IO String
showTime = do
zone <- getCurrentTimeZone
utc <- getCurrentTime
let local = utcToLocalTime zone utc
return $ formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S " local <> show zone
-- this should be patched upstream in Blaze
download :: H.AttributeValue -> H.Attribute
download = A.attribute "download" " download=\""
-- | Create an empty directory, deleting it beforehand if it already exists.
setupCleanDirectory :: FilePath -> IO ()
setupCleanDirectory dir = do
siteDirectoryExists <- doesDirectoryExist dir
when siteDirectoryExists $ removeDirectoryRecursive dir
createDirectory dir