ammocpr/lib/ammoprices/scraping/retailers/sg_ammo.ex
Graham McIntire bbe5bde145
Initial implementation of ammo price tracker
Phoenix 1.8 app that scrapes ammunition retailers (Lucky Gunner,
SGAmmo) for price data and displays historical price trends.

- Data model: retailers, calibers, products, price snapshots
- Scraper infrastructure with Req, Floki, realistic browser headers
- Oban-scheduled scrape jobs (every 4h with randomized delays)
- LiveView pages: homepage with category cards, caliber detail with
  price table, Chart.js price history, and price stats banner
- 18 seeded calibers across handgun/rifle/rimfire/shotgun categories
- 77 tests
2026-03-11 15:58:12 -05:00

163 lines
4.5 KiB
Elixir

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