defmodule Ammoprices.Scraping.Retailers.SgAmmo do @moduledoc false @behaviour Ammoprices.Scraping.Scraper @slug_to_path %{ "9mm-luger" => "/catalog/pistol-ammo-for-sale/9mm-luger-ammo", "45-acp" => "/catalog/pistol-ammo-for-sale/45-auto-acp-ammo", "380-acp" => "/catalog/pistol-ammo-for-sale/380-auto-ammo", "40-sw" => "/catalog/pistol-ammo-for-sale/40-cal-ammo", "38-special" => "/catalog/pistol-ammo-for-sale/38-special-ammo", "357-magnum" => "/catalog/pistol-ammo-for-sale/357-magnum-ammo", "10mm-auto" => "/catalog/pistol-ammo-for-sale/10mm-ammo", "556-223" => "/catalog/rifle-ammo-for-sale/223-556mm-ammo", "308-win" => "/catalog/rifle-ammo-for-sale/308-762mm-ammo", "762x39" => "/catalog/rifle-ammo-for-sale/762x39-ammo", "30-06" => "/catalog/rifle-ammo-for-sale/30-06-ammo", "300-blackout" => "/catalog/rifle-ammo-for-sale/300-aac-blackout-ammo", "65-creedmoor" => "/catalog/rifle-ammo-for-sale/65-creedmoor-ammo", "22-lr" => "/catalog/rimfire-ammo-for-sale/22-lr-ammo", "22-wmr" => "/catalog/rimfire-ammo-for-sale/22-wmr-ammo", "17-hmr" => "/catalog/rimfire-ammo-for-sale/17-hmr-ammo", "12-gauge" => "/catalog/shotgun-ammo-for-sale/12-gauge-ammo", "20-gauge" => "/catalog/shotgun-ammo-for-sale/20-gauge-ammo" } @impl true def retailer_slug, do: "sgammo" @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("tr.sgammo-content-product-item") |> Enum.map(&parse_row/1) |> Enum.reject(&is_nil/1) end defp parse_row(row) do with {:ok, title} <- extract_title(row), {:ok, url} <- extract_url(row), {:ok, price_cents} <- extract_price(row), {:ok, ppr_cents} <- extract_price_per_round(row) do %{ title: title, url: url, price_cents: price_cents, price_per_round_cents: ppr_cents, brand: extract_brand(title), grain_weight: extract_grain_weight(title), round_count: extract_round_count(title), casing: nil, in_stock: extract_in_stock(row) } else _ -> nil end end defp extract_title(row) do case Floki.find(row, ".sgammo-content-product-item__title a") do [{_, _, _} = node] -> {:ok, node |> Floki.text() |> String.trim()} _ -> :error end end defp extract_url(row) do case Floki.find(row, ".sgammo-content-product-item__title a") do [{_, attrs, _}] -> case List.keyfind(attrs, "href", 0) do {"href", href} -> {:ok, href} _ -> :error end _ -> :error end end defp extract_price(row) do case Floki.find(row, ".sgammo-content-product-item__col-price .woocommerce-Price-amount") 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(row) do price_col = row |> Floki.find(".sgammo-content-product-item__col-price") |> List.first() if price_col do text = Floki.text(price_col) 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 else :error end end defp extract_brand(title) do # SGAmmo titles typically have format: "NNN Round ... by BRAND - SKU" case Regex.run(~r/by\s+([A-Za-z][A-Za-z\s&]+?)(?:\s*-|\s*$)/i, title) do [_, brand] -> String.trim(brand) _ -> 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]ound/i, title) do [_, count] -> count |> String.replace(",", "") |> String.to_integer() _ -> nil end end defp extract_in_stock(row) do {_, _attrs, _} = row classes = [row] |> Floki.attribute("class") |> List.first() || "" String.contains?(classes, "instock") end end