Previously combined as a single "556-223" caliber. Now tracked separately with dedicated scraper URLs per retailer. Also fixes broken URLs for Lucky Gunner, True Shot Ammo, and Palmetto State Armory.
156 lines
4.1 KiB
Elixir
156 lines
4.1 KiB
Elixir
defmodule Ammoprices.Scraping.Retailers.BulkAmmo do
|
|
@moduledoc false
|
|
@behaviour Ammoprices.Scraping.Scraper
|
|
|
|
alias Ammoprices.Scraping.TextDetector
|
|
|
|
@slug_to_path %{
|
|
"9mm-luger" => "/handgun/bulk-9mm-ammo",
|
|
"45-acp" => "/handgun/bulk-45-acp-ammo",
|
|
"380-acp" => "/handgun/bulk-380-acp-ammo",
|
|
"40-sw" => "/handgun/bulk-40-sandw-ammo",
|
|
"38-special" => "/handgun/bulk-38-special-ammo",
|
|
"357-magnum" => "/handgun/bulk-357-mag-ammo",
|
|
"10mm-auto" => "/handgun/bulk-10mm-ammo",
|
|
"223-rem" => "/rifle/bulk-.223-ammo",
|
|
"556-nato" => "/rifle/bulk-5.56x45-ammo",
|
|
"308-win" => "/rifle/bulk-308-ammo",
|
|
"762x39" => "/rifle/bulk-7.62x39-ammo",
|
|
"30-06" => "/rifle/bulk-30-06-ammo",
|
|
"300-blackout" => "/rifle/bulk-300-blackout-ammo",
|
|
"65-creedmoor" => "/rifle/bulk-6.5-creedmoor-ammo",
|
|
"22-lr" => "/rimfire/bulk-22-lr-ammo",
|
|
"12-gauge" => "/shotgun/bulk-12-gauge-ammo",
|
|
"20-gauge" => "/shotgun/bulk-20-gauge-ammo"
|
|
}
|
|
|
|
@impl true
|
|
def retailer_slug, do: "bulk-ammo"
|
|
|
|
@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(".products-grid li.item")
|
|
|> 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) do
|
|
round_count = extract_round_count(title)
|
|
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
|
|
|
|
%{
|
|
title: title,
|
|
url: url,
|
|
price_cents: price_cents,
|
|
price_per_round_cents: ppr,
|
|
brand: extract_brand(title),
|
|
grain_weight: extract_grain_weight(title),
|
|
round_count: round_count,
|
|
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, "a.product-name") do
|
|
[{_, _, _} = node] -> {:ok, node |> Floki.text() |> String.trim()}
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
defp extract_url(item) do
|
|
case Floki.find(item, "a.product-name") do
|
|
[{_, attrs, _}] ->
|
|
case List.keyfind(attrs, "href", 0) do
|
|
{"href", href} -> {:ok, href}
|
|
_ -> :error
|
|
end
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
defp extract_price(item) do
|
|
# Prefer special-price (sale price), fall back to regular-price
|
|
price_text =
|
|
case Floki.find(item, ".special-price .price") do
|
|
[{_, _, _} = node] -> node |> Floki.text() |> String.trim()
|
|
_ -> extract_regular_price_text(item)
|
|
end
|
|
|
|
case price_text do
|
|
nil ->
|
|
:error
|
|
|
|
text ->
|
|
case Regex.run(~r/\$([0-9,]+(?:\.\d{2})?)/, text) do
|
|
[_, amount] ->
|
|
cents = parse_dollar_amount(amount)
|
|
{:ok, cents}
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
end
|
|
end
|
|
|
|
defp extract_regular_price_text(item) do
|
|
case Floki.find(item, ".regular-price .price") do
|
|
[{_, _, _} = node] -> node |> Floki.text() |> String.trim()
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_dollar_amount(amount) do
|
|
clean = String.replace(amount, ",", "")
|
|
|
|
if String.contains?(clean, ".") do
|
|
clean |> String.to_float() |> Kernel.*(100) |> round()
|
|
else
|
|
String.to_integer(clean) * 100
|
|
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_brand(title) do
|
|
case Regex.run(~r/by\s+(.+?)(?:\s*-|$)/, title) do
|
|
[_, brand] -> String.trim(brand)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_grain_weight(title) do
|
|
case Regex.run(~r/(\d+)\s*gr/i, title) do
|
|
[_, weight] -> String.to_integer(weight)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_in_stock(item) do
|
|
case Floki.find(item, "span.in-stock") do
|
|
[_ | _] -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
end
|