(import-macros {: map : reduce : filter} :lib.macro)
(tset package :path (.. package.path ";./vendor/lpeglj/?.lua"))
(local array (require :lib.array))
(local cache (require :lib.cache))
(local fs (require :lib.fs))
(local html (require :vendor.html))
(local libhtml (require :lib.html))
(local math (require :math))
(local spellfix (require :lib.spellfix))
(local libstr (require :lib.string))
(local synonyms (require :lib.synonyms))
(local texts (require :texts))
(local {: must} (require :lib.utils))
(when _G.unpack
(tset table :unpack _G.unpack))
(set _G.reload
(fn [module]
(local old (require module))
(tset package :loaded module nil)
(local (ok? new) (pcall require module))
(if (not ok?)
(do
(tset package :loaded module old)
(error new))
(when (= (type new) :table)
(do
(each [k v (pairs new)]
(tset old k v))
(each [k (pairs old)]
(when (not (. new k))
(tset old k nil)))
(tset package :loaded module old))))))
(local db
(must (luna.db.open "file:var/db.sqlite?_journal=WAL&_sync=NORMAL&_txlock=immediate")))
(fn sanitize-input [input]
(if input
(libstr.trim (input:gsub "[=()<>']" "") " ")
nil))
(fn query-string [query key]
(if (and query
(. query key)
(. query key 1)
(< 0 (# (. query key 1))))
(sanitize-input (. query key 1))
nil))
(fn query-number [query key]
(if (and query
(. query key)
(. query key 1)
(< 0 (# (. query key 1))))
(tonumber (sanitize-input (. query key 1)))
nil))
(fn serialize-query [query]
(local flattened-object
(collect [k v (pairs query)]
(values k (array.join (map (fn [_ vv] (.. k "=" vv)) v) "&"))))
(array.join (array.list flattened-object) "&"))
(fn collect-form [params]
{:query (or (query-string params "query") "")
:page (or (query-number params "page") 1)
:tags (map #(sanitize-input $2)
(filter #(~= "" $2) (or params.tags [])))
:site (or (query-string params "site") "")
:sort (or (query-string params "sort") "")
:min-price (query-number params "min-price")
:max-price (query-number params "max-price")
:min-volume (query-number params "min-volume")
:max-volume (query-number params "max-volume")
:price-per (= "on" (query-string params "price-per"))})
(fn form-empty? [form]
(and
(= "" form.query)
(= (# form.tags) 0)
(= "" form.site)
(not form.min-price)
(not form.max-price)
(not form.min-volume)
(not form.max-volume)
;; price-per and sort are intentionally left out since they must not
;; trigger search by itself
))
(fn form->path [page form]
(.. "?page=" (tostring page)
(if (not (libstr.empty? form.query))
(.. "&query=" form.query)
"")
(if (< 0 (# form.tags))
(.. "&"
(array.join
(array.flatten
(map (fn [_ tag] (.. "tags=" tag))
form.tags))
"&"))
"")
(if (not (libstr.empty? form.site))
(.. "&site=" form.site)
"")
(if (and form.min-price (< 0 form.min-price))
(.. "&min-price=" (tostring form.min-price))
"")
(if (and form.max-price (< 0 form.max-price))
(.. "&max-price=" (tostring form.max-price))
"")
(if form.price-per
"&price-per=on"
"")
(if (and form.min-volume (< 0 form.min-volume))
(.. "&min-volume=" (tostring form.min-volume))
"")
(if (and form.max-volume (< 0 form.max-volume))
(.. "&max-volume=" (tostring form.max-volume))
"")
(if form.sort
(.. "&sort=" form.sort)
"")))
(fn teas-of-the-day [limit]
(assert (< 0 limit) "limit must be > 0")
(local the-number (* (tonumber (os.date "%Y%m%d")) limit))
(local total
(. (must (luna.db.query db
"SELECT count(ROWID)
FROM (
SELECT products.ROWID
FROM products
INNER JOIN product_tags ON product_tags.product = products.url
WHERE archived = false
AND product_tags.tag IN ('Красный чай', 'Улун', 'Шен пуэр',
'Шу пуэр', 'Зеленый чай', 'Белый чай',
'Желтый чай')
GROUP BY products.url)" []))
1 1))
(must
(luna.db.query-assoc
db
"SELECT products.site,
products.title,
products.description,
products.image,
products.url,
products.price,
products.weight,
products.price_per AS \"price-per\"
FROM products
INNER JOIN product_tags ON product_tags.product = products.url
WHERE products.archived = false
AND product_tags.tag IN ('Красный чай', 'Улун', 'Шен пуэр', 'Шу пуэр',
'Зеленый чай', 'Белый чай', 'Желтый чай')
GROUP BY products.url
ORDER BY ROW_NUMBER() over (PARTITION BY site ORDER BY products.ROWID)
LIMIT ?
OFFSET ?"
[limit (% the-number (- total limit))])))
(fn now []
(os.date "%Y-%m-%d %H:%M:%S"))
(fn store-metric [headers url]
(local fingerprint
(must (luna.crypto.sha1
(.. (or headers.User-Agent "") ":"
(or headers.X-Forwarded-For
headers.Forwarded
headers.X-Real-Ip
"")))))
(must
(luna.db.exec
db
"INSERT INTO metrics(fingerprint, url, creation_time) VALUES (?, ?, ?)"
[fingerprint url (now)])))
(fn tracked-url [url]
(.. "/track?url=" (must (luna.http.encode-url url))))
(fn shops-count []
(# (must (luna.db.query db
"SELECT DISTINCT site
FROM products
WHERE archived = false" []))))
(fn products-count []
(. (must (luna.db.query db
"SELECT DISTINCT COUNT(url)
FROM products
WHERE archived = false" []))
1 1))
(fn latest-update-date []
(. (must (luna.db.query db
"SELECT update_time
FROM products
ORDER BY update_time DESC
LIMIT 1" []))
1 1))
(fn query-products [{: query : tags : min-price : max-price : price-per
: min-volume : max-volume : site : sort} page]
(local tags (or tags []))
(local where-conds [])
(local sort-criteria [])
(local query-args [])
(local has-search-query? (and query (< 0 (# query))))
;; create WHERE clause from dynamic form parameters
(when (< 0 (# tags))
(each [_ tag (pairs tags)]
(table.insert where-conds "product_tags.tag = ?")
(table.insert query-args tag)))
(when has-search-query?
(table.insert where-conds "search.title MATCH ?")
(table.insert query-args (synonyms.replace query)))
(when (not (libstr.empty? site))
(table.insert where-conds "products.site = ?")
(table.insert query-args site))
(when (and min-price (< 0 min-price))
(if price-per
(table.insert where-conds "products.price_per >= ?")
(table.insert where-conds "products.price >= ?"))
(table.insert query-args min-price))
(when (and max-price (< 0 max-price))
(if price-per
(table.insert where-conds "products.price_per <= ?")
(table.insert where-conds "products.price <= ?"))
(table.insert query-args max-price))
(when (and min-volume (< 0 min-volume))
(table.insert where-conds "products.volume >= ?")
(table.insert query-args min-volume))
(when (and max-volume (< 0 max-volume))
(table.insert where-conds
"(products.volume <= ? AND products.volume != 0)")
(table.insert query-args max-volume))
(local where-sql
(if (< 0 (# where-conds))
(.. "AND " (array.join where-conds "\nAND "))
""))
;; create SORT clause from dynamic form parameters
(when sort
(if (= sort "cheap-first")
(table.insert sort-criteria
;; multiple price by 100 if we only know a price for 1 gram
"CASE WHEN products.weight = 1 THEN products.price * 100
ELSE products.price
END ASC")
(= sort "expensive-first")
(table.insert sort-criteria
;; multiple price by 100 if we only know a price for 1 gram
"CASE WHEN products.weight = 1 THEN products.price * 100
ELSE products.price
END DESC")))
(local sort-sql
(if (< 0 (# sort-criteria))
(.. (array.join sort-criteria ",\n") ",")
""))
(local from-sql
(if has-search-query?
"search INNER JOIN products ON search.fid = products.url"
"products"))
(local total
(must
(luna.db.query
db
(string.format
"SELECT count(ROWID)
FROM (
SELECT products.ROWID
FROM %s
LEFT JOIN product_tags ON product_tags.product = products.url
WHERE products.archived = false
%s
GROUP BY products.url)" from-sql where-sql)
query-args)))
{:results
(must
(luna.db.query-assoc
db
(string.format
"SELECT products.title,
products.site,
products.description,
products.image,
products.url,
products.price,
products.weight,
products.price_per AS \"price-per\"
FROM %s
LEFT JOIN product_tags ON product_tags.product = products.url
LEFT JOIN permanent_products ON permanent_products.url = products.url
WHERE products.archived = false
%s
GROUP BY products.url
ORDER BY %s
ROW_NUMBER() OVER
(PARTITION BY products.site
ORDER BY permanent_products.creation_time DESC,
products.title ASC)
%s
LIMIT 24 OFFSET ?"
from-sql where-sql sort-sql (if has-search-query? ", rank" ""))
(array.concat query-args [(* (- page 1) 24)])))
:total (if (< 0 (# total))
(. total 1 1)
0)})
(fn site-name-template [name]
(local module (require (.. "parser." name)))
[:a {:class "site-icon"
:href (tracked-url (.. module.url "?from=everytea.ru"))}
[:img {:src (.. "/static/" name ".webp") :alt (.. "Логотип " module.title)}]
module.title])
(fn item-template [product]
(local link (tracked-url (.. product.url "?from=everytea.ru")))
[:div {:class "tile"}
[:a {:href link :class "img-link" :rel "nofollow"}
[:img {:class "img" :src product.image :title product.title
:alt product.title} ""]]
(site-name-template product.site)
[:a {:href link :style "text-decoration: none;" :rel "nofollow"}
[:NO-ESCAPE (.. "
"
(libhtml.unescape product.title)
"
")]]
[:div {:class "price"}
(if product.price (.. (libstr.format-price product.price) "₽") "")
(if (< 0 product.weight)
[:NO-ESCAPE (.. "за " product.weight " гр.")]
"")
(if (and (< 1 product.weight)
product.price-per
(< 0 product.price-per))
[:NO-ESCAPE (.. "
(" (libstr.format-price product.price-per)
"₽ за 1 гр.)")]
"")]])
(fn paginator-template [form page limit total max-numbers]
(local last-page (math.ceil (/ total limit)))
(local radius (math.floor (/ (- max-numbers 4) 2)))
(local number-items
(if (<= last-page max-numbers)
(faccumulate [res [:span {}] i 1 last-page]
(do
(table.insert
res [:a {:href (form->path i form)
:class (if (= page i) "paginator-active" "")}
(tostring i)])
res))
[:span {}
(table.unpack
(array.concat
(if (< (+ 1 radius) page)
[[:a {:href (form->path 1 form)} "1"]
(if (< (+ 2 radius) page) [:span {} "…"] "")]
[])
(faccumulate [res [] i (math.max 1 (- page radius))
(math.min last-page (+ page radius))]
(do
(table.insert
res [:a {:href (form->path i form)
:class (if (= page i) "paginator-active" "")}
(tostring i)])
res))
(if (< radius (- last-page page))
[(if (< (+ 1 radius) (- last-page page)) [:span {} "…"] "")
[:a {:href (form->path last-page form)} (tostring last-page)]]
[])))]))
(if (< limit total)
[:div {:class "paginator"}
[:div {:class "paginator-numbers"} number-items]
[:div {} "Всего: " [:strong {} (string.format "%d" total)]]]
""))
(fn menu-template [path extra-class]
(local item
(fn [item-path title]
[:a {:href item-path
:class (if (= path item-path) "active" "")}
title]))
[:nav {:class (.. "menu" (if (libstr.empty? extra-class)
""
(.. " " extra-class)))}
(item "/red-tea" "Красный чай")
(item "/sheng-puer" "Шен пуэр")
(item "/shou-puer" "Шу пуэр")
(item "/oolong" "Улун")
(item "/green-tea" "Зеленый чай")
(item "/white-tea" "Белый чай")
(item "/yellow-tea" "Желтый чай")
(item "/teaware" "Посуда")
(item "/tags" "Все категории")])
(fn aside-template [form path aside-content]
(local teaware? (array.contains form.tags "Посуда"))
[:aside {:class "aside"}
[:div {:class "aside-content"}
(if (or (not (form-empty? form)) (~= path "/"))
[:a {:href "/" :style "display: block;"}
[:img {:class "logo" :src "/static/logo.svg"
:alt "Логотип everytea.ru" :title "Логотип everytea.ru"}]]
[:img {:class "logo" :src "/static/logo.svg"
:alt "Логотип everytea.ru" :title "Логотип everytea.ru"}])
(menu-template path "menu-mobile")
[:form {:class "form"}
[:input {:type :search :name "query" :value form.query
:placeholder "Поисковый запрос"}]
[:div {:class "form-range"}
[:input {:type :number :name "min-price" :min "1"
:placeholder "От ₽" :value (tostring form.min-price)}]
[:input {:type :number :name "max-price" :min "1"
:placeholder "До ₽" :value (tostring form.max-price)}]]
(if teaware?
[:div {:class "form-range"}
[:input {:type :number :name "min-volume" :min "1"
:placeholder "От мл" :value (tostring form.min-volume)}]
[:input {:type :number :name "max-volume" :min "1"
:placeholder "До мл" :value (tostring form.max-volume)}]]
"")
(if (not teaware?)
[:div {:class "form-price-per"}
[:input {:type :checkbox :id "price-per" :name "price-per"
:checked (if form.price-per "checked" nil)}]
[:label {:for "price-per"} "цена за грамм"]]
"")
[:div {}
[:select {:name "site"}
[:option {:value ""} "~ Сайт ~"]
(table.unpack
(map
(fn [_ val]
[:option {:value val
:selected (if (= form.site val) "selected" nil)}
(. (require (.. "parser." val)) :title)])
[:suhexuan :ozchai :batatcha :kolokolnikovchai :yoceramics :tea108
:ipuer :clubcha :daochai :thailandtea :chaekshop :teaworkshop
:chaibez :artoftea :moychay :gorkovchay]))]]
[:div {}
[:select {:name "sort"}
[:option {:value ""} "~ Порядок ~"]
[:option {:value "cheap-first"
:selected (if (= form.sort "cheap-first") "selected" nil)}
"Сначала дешевле"]
[:option {:value "expensive-first"
:selected (if (= form.sort "expensive-first") "selected" nil)}
"Сначала дороже"]]]
[:button {:type :submit} "Искать"]]
(if aside-content aside-content "")]])
(fn home-template []
[:div {}
[:article {:class "description"}
[:h1 {} "Добро пожаловать!"]
[:div {:class "description-text text"}
[:p {} [:NO-ESCAPE
(string.format texts.home-text
(shops-count)
(products-count)
(latest-update-date))]]]]
[:section {}
[:h2 {} "Чай дня"]
[:div {:class "list"}
(table.unpack (map #(item-template $2) (teas-of-the-day 8)))]]])
(fn all-tags []
(must
(luna.db.query-assoc
db "SELECT count(*) AS count,
tag AS title
FROM product_tags
INNER JOIN products ON product_tags.product = products.url
WHERE products.archived = false
GROUP BY tag
ORDER BY tag ASC"
[])))
(fn tags-template []
[:div {}
[:article {:class "description"}
[:h1 {} "Все категории"]]
[:ul {:class "tags-list"}
(table.unpack
(map (fn [_ tag]
[:li {}
[:a {:href (.. "/?tags=" tag.title)}
(.. tag.title " (" (tostring tag.count) ")")]])
(all-tags)))]])
(fn base-template [form path content aside-content]
(local title (if (libstr.empty? form.query)
texts.meta-title
(.. form.query " | " texts.meta-title)))
(local canonical-url (libstr.trim (.. "https://everytea.ru" path) "/"))
[:html {:lang "ru-RU"}
[:head {}
[:meta {:charset "utf-8"}]
[:meta {:name "viewport"
:content (.. "width=device-width,initial-scale=1,"
"minimum-scale=1.0,maximum-scale=5.0")}]
;; opengraph
[:meta {:property "og:title" :content title}]
[:meta {:property "og:description" :content texts.meta-description}]
[:meta {:property "og:type" :content "website"}]
[:meta {:property "og:url" :content canonical-url}]
[:meta {:property "og:image"
:content "https://everytea.ru/static/og:image.png"}]
[:meta {:property "twitter:image"
:content "https://everytea.ru/static/og:image.png"}]
[:meta {:property "vk:image"
:content "https://everytea.ru/static/og:image.png"}]
[:meta {:property "og:image:width" :content "1200"}]
[:meta {:property "og:image:height" :content "630"}]
[:meta {:property "twitter:card" :content "summary_large_image"}]
[:meta {:property "og:locale" :content "ru_RU"}]
;; inline styles so the page loads faster
[:style {} [:NO-ESCAPE (fs.read-file "static/style.css")]]
[:link {:rel "icon" :href "https://everytea.ru/static/favicon.svg?v=2"
:type "image/svg+xml"}]
[:link {:rel "preload" :fetchpriority "high" :as "image"
:href "/static/logo-hor.svg" :type "image/svg+xml"}]
[:link {:rel "canonical" :href canonical-url}]
[:title {} title]
[:meta {:name "description" :content texts.meta-description}]]
[:body {}
[:div {:class "container"}
[:div {:class "content"}
(aside-template form path aside-content)
[:main {}
(menu-template path)
content
[:footer {}
[:small {:class "text"} [:NO-ESCAPE texts.footer-text]]]]]]]])
(fn root-handler [{: headers : path : query}]
(local path-with-query (.. path "?" (serialize-query query)))
;; FIXME: do not run render-listing when cache is hit
(local cache-key (.. "page:" path-with-query))
(local cached (cache.get db cache-key))
(store-metric headers path-with-query)
(fn render-listing [form category]
(local headers {:content-type "text/html"})
(when (and category (= 0 (# form.tags)))
(tset form.tags 1 category))
(local {: results : total} (query-products form form.page))
(local spellfix-suggestion
(if (and (not (libstr.empty? form.query)) results (= 0 (# results)))
(cache.wrap
db (.. "spellfix:" form.query)
#(spellfix.guess form.query))
nil))
(local description-key (.. (libstr.trim path "/") "-description"))
(local page
(base-template form path
[:div {}
(if (and (. form.tags 1) (. texts description-key))
[:article {:class "description"}
[:h1 {} (. form.tags 1)]
[:div {:class "description-text text"}
[:NO-ESCAPE (. texts description-key)]]]
"")
(if (< 0 (# results))
[:div {:class "list"}
(table.unpack (map #(item-template $2) results))]
[:p {:class "text"}
(if spellfix-suggestion
[:NO-ESCAPE
(string.format texts.no-results-with-suggestion
spellfix-suggestion
spellfix-suggestion)]
[:NO-ESCAPE texts.no-results])])
[:div {} (paginator-template form form.page 24 total 17)]]
;; aside
(paginator-template form form.page 24 total 7)))
(values
200 headers
(cache.set db cache-key
(.. "\n" (html.render page true)))))
(if (and (not luna.debug) cached)
(values 200 {:content-type "text/html"} cached)
(do
(local form (collect-form query))
(match (libstr.split path "/")
["red-tea"] (render-listing form "Красный чай")
["sheng-puer"] (render-listing form "Шен пуэр")
["shou-puer"] (render-listing form "Шу пуэр")
["oolong"] (render-listing form "Улун")
["green-tea"] (render-listing form "Зеленый чай")
["white-tea"] (render-listing form "Белый чай")
["yellow-tea"] (render-listing form "Желтый чай")
["teaware"] (render-listing form "Посуда")
["tags"] (values
200 {:content-type "text/html"}
(.. "\n"
(html.render (base-template form path (tags-template))
true)))
(where t (= 0 (# t)))
(if (form-empty? form)
(values
200 {:content-type "text/html"}
(.. "\n"
(html.render (base-template form path (home-template))
true)))
(render-listing form))
_ (values
404 {:content-type "text/html"}
(.. "\n"
(html.render
(base-template form path [:div {} "Страница не найдена!"])
true)))))))
(fn robots-handler []
(values 200 {:content-type "text/plain"}
"User-agent: *\nAllow: /"))
(fn track-handler [{: headers : query}]
(local redirect-url (?. query :url 1))
(if redirect-url
(do
(store-metric headers redirect-url)
(values 307 {:Location redirect-url} ""))
(values 400 {} "bad redirect url")))
(must (luna.router.route "GET /" root-handler))
(must (luna.router.route "GET /robots.txt" robots-handler))
(must (luna.router.static "GET /static/" "static/" "public, max-age=3600"))
(must (luna.router.route "GET /track" track-handler))
(when luna.debug
(must (luna.on-eval (fn [code] (fennel.eval code {:env _G})))))