defmodule Ammoprices.Scraping.Retailers.LuckyGunner do @moduledoc false @behaviour Ammoprices.Scraping.Scraper @slug_to_path %{ "9mm-luger" => "/handgun/9mm-ammo", "45-acp" => "/handgun/45-acp-ammo", "380-acp" => "/handgun/380-ammo", "40-sw" => "/handgun/40-sw-ammo", "38-special" => "/handgun/38-special-ammo", "357-magnum" => "/handgun/357-mag-ammo", "10mm-auto" => "/handgun/10mm-ammo", "556-223" => "/rifle/223-ammo", "308-win" => "/rifle/308-ammo", "762x39" => "/rifle/7.62x39-ammo", "30-06" => "/rifle/30-06-ammo", "300-blackout" => "/rifle/300-blackout-ammo", "65-creedmoor" => "/rifle/6.5-creedmoor-ammo", "22-lr" => "/rimfire/22-lr-ammo", "22-wmr" => "/rimfire/22-wmr-ammo", "17-hmr" => "/rimfire/17-hmr-ammo", "12-gauge" => "/shotgun/12-gauge-ammo", "20-gauge" => "/shotgun/20-gauge-ammo" } @impl true def retailer_slug, do: "lucky-gunner" @impl true def category_url(caliber) do Map.get(@slug_to_path, caliber.slug) end @impl true def parse_products(html) do html |> Floki.parse_document!() |> Floki.find(".products-list .item") |> Enum.map(&parse_item/1) |> Enum.reject(&is_nil/1) end defp parse_item(item) do with {:ok, title} <- extract_title(item), {:ok, url} <- extract_url(item), {:ok, price_cents} <- extract_price(item), {:ok, ppr_cents} <- extract_price_per_round(item) do desc_items = extract_description_items(item) %{ title: title, url: url, price_cents: price_cents, price_per_round_cents: ppr_cents, brand: extract_brand(desc_items), grain_weight: extract_grain_weight(desc_items), round_count: extract_round_count(title), casing: extract_casing(desc_items), in_stock: extract_in_stock(item) } else _ -> nil end end defp extract_title(item) do case Floki.find(item, ".product-name a span") do [{_, _, children}] -> {:ok, children |> Floki.text() |> String.trim()} _ -> :error end end defp extract_url(item) do case Floki.find(item, ".product-name a") do [{_, attrs, _}] -> case List.keyfind(attrs, "href", 0) do {"href", href} -> {:ok, href} _ -> :error end _ -> :error end end defp extract_price(item) do case Floki.find(item, ".regular-price") do [{_, _, _} = node] -> text = node |> Floki.text() |> String.trim() case Regex.run(~r/\$([0-9,]+\.\d{2})/, text) do [_, amount] -> cents = amount |> String.replace(",", "") |> String.to_float() |> Kernel.*(100) |> round() {:ok, cents} _ -> :error end _ -> :error end end defp extract_price_per_round(item) do case Floki.find(item, ".cprc") do [{_, _, _} = node] -> text = node |> Floki.text() |> String.trim() case Regex.run(~r/([0-9.]+)ยข/, text) do [_, amount] -> cents = if String.contains?(amount, ".") do amount |> String.to_float() |> trunc() else String.to_integer(amount) end {:ok, cents} _ -> :error end _ -> :error end end defp extract_description_items(item) do item |> Floki.find(".desc li") |> Enum.map(fn node -> node |> Floki.text() |> String.trim() end) end defp extract_brand(desc_items) do Enum.find_value(desc_items, fn item -> case Regex.run(~r/Manufacturer\s*-\s*(.+)/i, item) do [_, brand] -> String.trim(brand) _ -> nil end end) end defp extract_grain_weight(desc_items) do Enum.find_value(desc_items, fn item -> case Regex.run(~r/(\d+)\s*grain/i, item) do [_, weight] -> String.to_integer(weight) _ -> nil end end) end defp extract_round_count(title) do case Regex.run(~r/(\d[\d,]*)\s*[Rr]ounds?/i, title) do [_, count] -> count |> String.replace(",", "") |> String.to_integer() _ -> nil end end defp extract_casing(desc_items) do casing_text = Enum.find_value(desc_items, fn item -> case Regex.run(~r/[Cc]asings?\s*-\s*(.+)/i, item) do [_, text] -> String.downcase(String.trim(text)) _ -> nil end end) case casing_text do nil -> nil text when is_binary(text) -> detect_casing_material(text) end end defp detect_casing_material(text) do cond do String.contains?(text, "brass") -> "brass" String.contains?(text, "steel") -> "steel" String.contains?(text, "aluminum") -> "aluminum" String.contains?(text, "nickel") -> "brass" true -> nil end end defp extract_in_stock(item) do case Floki.find(item, ".in-stock") do [_ | _] -> true _ -> false end end end