defmodule Ammoprices.Scraping.Retailers.Natchez do @moduledoc false @behaviour Ammoprices.Scraping.Scraper alias Ammoprices.Scraping.HttpClient alias Ammoprices.Scraping.TextDetector @slug_to_caliber %{ "9mm-luger" => "9mm Luger", "45-acp" => "45 Auto", "380-acp" => "380 Auto", "40-sw" => "40 S&W", "38-special" => "38 Spl", "357-magnum" => "357 Mag", "10mm-auto" => "10mm Auto", "223-rem" => "223 Rem", "556-nato" => "5.56mm", "308-win" => "308 Win", "762x39" => "7.62x39mm", "30-06" => "30-06 Sprg", "300-blackout" => "300 Blk", "65-creedmoor" => "6.5 Creedmoor", "22-lr" => "22 LR", "22-wmr" => "22 WMR", "17-hmr" => "17 HMR", "12-gauge" => "12 Gauge", "16-gauge" => "16 Gauge", "20-gauge" => "20 Gauge" } @impl true def retailer_slug, do: "natchez" @impl true def category_url(_caliber), do: nil @impl true def parse_products(_), do: [] @impl true def fetch(retailer, caliber) do case build_query(caliber.slug) do nil -> {:ok, []} query -> url = retailer.base_url <> "/graphql" case HttpClient.post_json(url, %{query: query}, [{"store", "default"}]) do {:ok, %{status: 200, body: body}} -> {:ok, parse_graphql_response(body)} {:ok, %{status: s}} -> {:error, "HTTP #{s}"} {:error, reason} -> {:error, reason} end end end def build_query(slug) do case Map.get(@slug_to_caliber, slug) do nil -> nil caliber_value -> escaped = escape_gql_string(caliber_value) """ { products( search: "#{escaped}" filter: { caliber: { eq: "#{escaped}" } } pageSize: 48 currentPage: 1 ) { items { name sku url_key stock_status brand grain rounds case_material price_range { minimum_price { final_price { value } } } } total_count page_info { total_pages } } } """ end end defp escape_gql_string(str) do str |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") |> String.replace("\n", "\\n") end def parse_graphql_response(%{"data" => %{"products" => %{"items" => items}}}) do items |> Enum.map(&parse_item/1) |> Enum.reject(&is_nil/1) end def parse_graphql_response(_), do: [] defp parse_item(item) do case extract_price(item) do {:ok, price_cents} -> round_count = parse_integer(item["rounds"]) ppr = if round_count && round_count > 0, do: round(price_cents / round_count) name = item["name"] %{ title: name, url: "/#{item["url_key"]}.html", brand: item["brand"], price_cents: price_cents, price_per_round_cents: ppr, grain_weight: parse_integer(item["grain"]), round_count: round_count, casing: parse_casing(item["case_material"]), subsonic: TextDetector.detect_subsonic(name), in_stock: item["stock_status"] == "IN_STOCK" } :error -> nil end end defp extract_price(%{"price_range" => %{"minimum_price" => %{"final_price" => %{"value" => value}}}}) do {:ok, value |> String.to_float() |> Kernel.*(100) |> round()} end defp extract_price(_), do: :error defp parse_integer(nil), do: nil defp parse_integer(str) when is_binary(str) do case Integer.parse(str) do {n, _} -> n :error -> nil end end defp parse_integer(_), do: nil defp parse_casing(nil), do: nil defp parse_casing(material), do: String.downcase(material) end