ammocpr/lib/ammoprices/scraping/retailers/true_shot_ammo.ex
Graham McIntire 9eb85faebd
Add 4 new retailer scrapers for broader price coverage
Add True Shot Ammo (Shopify JSON), Palmetto State Armory (Magento HTML),
Bulk Ammo (Magento HTML), and Natchez Shooters Supply (GraphQL API).

Extends scraper infrastructure with HttpClient.post_json/3 and an
optional fetch/2 callback on the Scraper behaviour for scrapers that
need custom HTTP flows (e.g. GraphQL POST requests).
2026-03-12 10:00:59 -05:00

97 lines
2.7 KiB
Elixir

defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
alias Ammoprices.Scraping.TextDetector
@slug_to_collection %{
"9mm-luger" => "ammunition-pistol-ammo-9mm",
"45-acp" => "ammunition-pistol-ammo-45-acp",
"380-acp" => "ammunition-pistol-ammo-380-acp",
"40-sw" => "ammunition-pistol-ammo-40-s-w",
"38-special" => "ammunition-pistol-ammo-38-special",
"357-magnum" => "ammunition-pistol-ammo-357-magnum",
"10mm-auto" => "ammunition-pistol-ammo-10mm",
"556-223" => "ammunition-rifle-ammo-223-5-56",
"308-win" => "ammunition-rifle-ammo-308-7-62x51",
"762x39" => "ammunition-rifle-ammo-7-62x39",
"300-blackout" => "ammunition-rifle-ammo-300-blackout",
"65-creedmoor" => "ammunition-rifle-ammo-6-5-creedmoor",
"22-lr" => "ammunition-rimfire-ammo-22-lr",
"12-gauge" => "ammunition-shotgun-ammo-12-gauge",
"20-gauge" => "ammunition-shotgun-ammo-20-gauge"
}
@impl true
def retailer_slug, do: "true-shot-ammo"
@impl true
def category_url(caliber) do
case Map.get(@slug_to_collection, caliber.slug) do
nil -> nil
collection -> "/collections/#{collection}/products.json"
end
end
@impl true
def parse_products(%{"products" => products}) do
products
|> Enum.map(&parse_product/1)
|> Enum.reject(&is_nil/1)
end
def parse_products(_), do: []
defp parse_product(product) do
title = product["title"]
first_variant = List.first(product["variants"])
case first_variant do
nil ->
nil
variant ->
price_cents = parse_price(variant["price"])
round_count = parse_round_count(variant["title"])
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
%{
title: title,
url: "/products/#{product["handle"]}",
brand: product["vendor"],
price_cents: price_cents,
price_per_round_cents: ppr,
grain_weight: extract_grain_weight(title),
round_count: round_count,
casing: TextDetector.detect_casing(title),
subsonic: TextDetector.detect_subsonic(title),
in_stock: variant["available"] == true
}
end
end
defp parse_price(price_str) when is_binary(price_str) do
price_str
|> String.to_float()
|> Kernel.*(100)
|> round()
end
defp parse_price(_), do: 0
defp parse_round_count(title) when is_binary(title) do
case Integer.parse(title) do
{count, _} -> count
:error -> nil
end
end
defp parse_round_count(_), do: nil
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
end