defmodule Ammoprices.Scraping.Retailers.TargetSportsUsa do @moduledoc false @behaviour Ammoprices.Scraping.Scraper alias Ammoprices.Scraping.TextDetector @slug_to_path %{ "9mm-luger" => "/9mm-luger-ammo-c-51.aspx", "45-acp" => "/45-acp-auto-ammo-c-70.aspx", "380-acp" => "/380-acp-auto-ammo-c-50.aspx", "40-sw" => "/40-sw-ammo-c-59.aspx", "38-special" => "/38-special-ammo-c-56.aspx", "357-magnum" => "/357-magnum-ammo-c-57.aspx", "10mm-auto" => "/10mm-auto-ammo-c-60.aspx", "223-rem" => "/223-remington-ammo-c-83.aspx", "556-nato" => "/556mm-nato-ammo-c-2719.aspx", "308-win" => "/308-winchester-ammo-c-101.aspx", "762x39" => "/762x39mm-ammo-c-108.aspx", "30-06" => "/30-06-springfield-ammo-c-105.aspx", "300-blackout" => "/300-aac-blackout-ammo-c-969.aspx", "65-creedmoor" => "/65-creedmoor-ammo-c-776.aspx", "22-lr" => "/22-long-rifle-ammo-c-202.aspx", "22-wmr" => "/22-wmr-ammo-c-203.aspx", "17-hmr" => "/17-hmr-ammo-c-198.aspx", "12-gauge" => "/12-gauge-shotgun-ammo-c-747.aspx", "16-gauge" => "/16-gauge-shotgun-ammo-c-748.aspx", "20-gauge" => "/20-gauge-shotgun-ammo-c-749.aspx" } @impl true def retailer_slug, do: "target-sports-usa" @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("ul.product-list > li") |> 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 %{ title: title, url: url, price_cents: price_cents, price_per_round_cents: ppr_cents, brand: extract_brand(item), grain_weight: extract_grain_weight(title), round_count: extract_round_count(title), casing: TextDetector.detect_casing(title), subsonic: TextDetector.detect_subsonic(title), in_stock: extract_in_stock(item) } else _ -> nil end end defp extract_title(item) do case Floki.find(item, "h2") do [{_, _, _children} = h2] -> # Full h2 text minus the brand text full_text = h2 |> Floki.text() |> String.trim() brand_text = case Floki.find(item, "h2 > strong") do [{_, _, _} = strong] -> strong |> Floki.text() |> String.trim() _ -> "" end title = full_text |> String.replace(brand_text, "") |> String.trim() if title == "", do: :error, else: {:ok, title} _ -> :error end end defp extract_url(item) do case Floki.find(item, "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, ".product-listing-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, ".product-listing-price span") do [{_, _, _} = node | _] -> text = node |> Floki.text() |> String.trim() case Regex.run(~r/\$([0-9.]+)\s*Per Round/i, text) do [_, amount] -> cents = amount |> String.to_float() |> Kernel.*(100) |> round() {:ok, cents} _ -> :error end _ -> :error end end defp extract_brand(item) do case Floki.find(item, "h2 > strong") do [{_, _, _} = strong] -> strong |> Floki.text() |> String.trim() |> String.replace(~r/\s*(Ammunition|Ammo)\s*$/i, "") |> String.trim() _ -> nil end end defp extract_grain_weight(title) do case Regex.run(~r/(\d+)\s*[Gg]rain/i, title) do [_, weight] -> String.to_integer(weight) _ -> nil 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_in_stock(item) do case Floki.find(item, ".add-to-cart") do [{_, _, _} = node] -> text = node |> Floki.text() |> String.trim() text == "Add To Cart" _ -> false end end end