ammocpr/lib/ammoprices/scraping/retailers/target_sports_usa.ex
Graham McIntire 16a28ac01c
Add Target Sports USA scraper, product filtering, and subsonic detection
- Add subsonic boolean field to products with composite filter indexes
- Add TextDetector utility for detecting subsonic/casing from product titles
- Wire TextDetector into SgAmmo (casing + subsonic) and LuckyGunner (subsonic + fallback casing)
- Extend latest_prices_for_caliber with casing, grain_weight, and subsonic filter options
- Add distinct_grain_weights_for_caliber and distinct_casings_for_caliber queries
- Add filter UI with toggleable casing, grain weight, and subsonic buttons
- Add grain weight column and subsonic badge to product table
- Refresh filter options on PubSub price updates for real-time UI
- Implement Target Sports USA scraper with 18 caliber mappings
- Display all prices in dollar format ($0.38 instead of 38¢)
2026-03-11 16:28:04 -05:00

191 lines
4.8 KiB
Elixir

defmodule Ammoprices.Scraping.Retailers.TargetSportsUsa do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
alias Ammoprices.Scraping.TextDetector
@slug_to_path %{
"9mm-luger" => "/9mm-luger-ammo-c-51.aspx",
"45-acp" => "/45-acp-auto-ammo-c-70.aspx",
"380-acp" => "/380-acp-auto-ammo-c-50.aspx",
"40-sw" => "/40-sw-ammo-c-59.aspx",
"38-special" => "/38-special-ammo-c-56.aspx",
"357-magnum" => "/357-magnum-ammo-c-57.aspx",
"10mm-auto" => "/10mm-auto-ammo-c-60.aspx",
"556-223" => "/223-remington-ammo-c-83.aspx",
"308-win" => "/308-winchester-ammo-c-101.aspx",
"762x39" => "/762x39mm-ammo-c-108.aspx",
"30-06" => "/30-06-springfield-ammo-c-105.aspx",
"300-blackout" => "/300-aac-blackout-ammo-c-969.aspx",
"65-creedmoor" => "/65-creedmoor-ammo-c-776.aspx",
"22-lr" => "/22-long-rifle-ammo-c-202.aspx",
"22-wmr" => "/22-wmr-ammo-c-203.aspx",
"17-hmr" => "/17-hmr-ammo-c-198.aspx",
"12-gauge" => "/12-gauge-shotgun-ammo-c-747.aspx",
"20-gauge" => "/20-gauge-shotgun-ammo-c-749.aspx"
}
@impl true
def retailer_slug, do: "target-sports-usa"
@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("ul.product-list > li")
|> 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),
{:ok, ppr_cents} <- extract_price_per_round(item) do
%{
title: title,
url: url,
price_cents: price_cents,
price_per_round_cents: ppr_cents,
brand: extract_brand(item),
grain_weight: extract_grain_weight(title),
round_count: extract_round_count(title),
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, "h2") do
[{_, _, _children} = h2] ->
# Full h2 text minus the <strong> brand text
full_text = h2 |> Floki.text() |> String.trim()
brand_text =
case Floki.find(item, "h2 > strong") do
[{_, _, _} = strong] -> strong |> Floki.text() |> String.trim()
_ -> ""
end
title =
full_text
|> String.replace(brand_text, "")
|> String.trim()
if title == "", do: :error, else: {:ok, title}
_ ->
:error
end
end
defp extract_url(item) do
case Floki.find(item, "a") do
[{_, attrs, _} | _] ->
case List.keyfind(attrs, "href", 0) do
{"href", href} -> {:ok, href}
_ -> :error
end
_ ->
:error
end
end
defp extract_price(item) do
case Floki.find(item, ".product-listing-price") 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(item) do
case Floki.find(item, ".product-listing-price span") do
[{_, _, _} = node | _] ->
text = node |> Floki.text() |> String.trim()
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
_ ->
:error
end
end
defp extract_brand(item) do
case Floki.find(item, "h2 > strong") do
[{_, _, _} = strong] ->
strong
|> Floki.text()
|> String.trim()
|> String.replace(~r/\s*(Ammunition|Ammo)\s*$/i, "")
|> String.trim()
_ ->
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]ounds?/i, title) do
[_, count] -> count |> String.replace(",", "") |> String.to_integer()
_ -> nil
end
end
defp extract_in_stock(item) do
case Floki.find(item, ".add-to-cart") do
[{_, _, _} = node] ->
text = node |> Floki.text() |> String.trim()
text == "Add To Cart"
_ ->
false
end
end
end