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¢)
This commit is contained in:
Graham McIntire 2026-03-11 16:28:04 -05:00
parent bbe5bde145
commit 16a28ac01c
No known key found for this signature in database
26 changed files with 1105 additions and 41 deletions

View file

@ -67,7 +67,18 @@ defmodule Ammoprices.Catalog do
|> Repo.insert(
on_conflict:
{:replace,
[:title, :brand, :grain_weight, :round_count, :casing, :condition, :in_stock, :last_seen_at, :updated_at]},
[
:title,
:brand,
:grain_weight,
:round_count,
:casing,
:condition,
:in_stock,
:subsonic,
:last_seen_at,
:updated_at
]},
conflict_target: [:retailer_id, :url],
returning: true
)
@ -81,4 +92,24 @@ defmodule Ammoprices.Catalog do
|> limit(^limit)
|> Repo.all()
end
def distinct_grain_weights_for_caliber(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], not is_nil(p.grain_weight))
|> distinct(true)
|> select([p], p.grain_weight)
|> order_by([p], asc: p.grain_weight)
|> Repo.all()
end
def distinct_casings_for_caliber(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], not is_nil(p.casing))
|> distinct(true)
|> select([p], p.casing)
|> order_by([p], asc: p.casing)
|> Repo.all()
end
end

View file

@ -20,6 +20,7 @@ defmodule Ammoprices.Catalog.Product do
field :upc, :string
field :external_id, :string
field :in_stock, :boolean, default: true
field :subsonic, :boolean, default: false
field :last_seen_at, :utc_datetime
has_many :price_snapshots, Ammoprices.Prices.PriceSnapshot
@ -40,6 +41,7 @@ defmodule Ammoprices.Catalog.Product do
:upc,
:external_id,
:in_stock,
:subsonic,
:last_seen_at
])
|> validate_required([:title, :url])

View file

@ -22,6 +22,9 @@ defmodule Ammoprices.Prices do
def latest_prices_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
in_stock = Keyword.get(opts, :in_stock)
casing = Keyword.get(opts, :casing)
grain_weight = Keyword.get(opts, :grain_weight)
subsonic = Keyword.get(opts, :subsonic)
latest_per_product =
from(ps in PriceSnapshot,
@ -30,6 +33,9 @@ defmodule Ammoprices.Prices do
distinct: ps.product_id,
order_by: [asc: ps.product_id, desc: ps.recorded_at]
)
|> maybe_filter_product(:casing, casing)
|> maybe_filter_product(:grain_weight, grain_weight)
|> maybe_filter_product(:subsonic, subsonic)
query =
from(ps in subquery(latest_per_product),
@ -47,6 +53,20 @@ defmodule Ammoprices.Prices do
Repo.all(query)
end
defp maybe_filter_product(query, _field, nil), do: query
defp maybe_filter_product(query, :casing, value) do
where(query, [_ps, p], p.casing == ^value)
end
defp maybe_filter_product(query, :grain_weight, value) do
where(query, [_ps, p], p.grain_weight == ^value)
end
defp maybe_filter_product(query, :subsonic, value) do
where(query, [_ps, p], p.subsonic == ^value)
end
@doc """
Returns daily aggregates (avg, min, max) of price_per_round_cents
for in-stock products of a caliber.

View file

@ -2,6 +2,8 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunner do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
alias Ammoprices.Scraping.TextDetector
@slug_to_path %{
"9mm-luger" => "/handgun/9mm-ammo",
"45-acp" => "/handgun/45-acp-ammo",
@ -55,7 +57,8 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunner do
brand: extract_brand(desc_items),
grain_weight: extract_grain_weight(desc_items),
round_count: extract_round_count(title),
casing: extract_casing(desc_items),
casing: extract_casing(desc_items) || TextDetector.detect_casing(title),
subsonic: TextDetector.detect_subsonic(title),
in_stock: extract_in_stock(item)
}
else

View file

@ -2,6 +2,8 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do
@moduledoc false
@behaviour Ammoprices.Scraping.Scraper
alias Ammoprices.Scraping.TextDetector
@slug_to_path %{
"9mm-luger" => "/catalog/pistol-ammo-for-sale/9mm-luger-ammo",
"45-acp" => "/catalog/pistol-ammo-for-sale/45-auto-acp-ammo",
@ -53,7 +55,8 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do
brand: extract_brand(title),
grain_weight: extract_grain_weight(title),
round_count: extract_round_count(title),
casing: nil,
casing: TextDetector.detect_casing(title),
subsonic: TextDetector.detect_subsonic(title),
in_stock: extract_in_stock(row)
}
else

View file

@ -0,0 +1,191 @@
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

View file

@ -35,6 +35,7 @@ defmodule Ammoprices.Scraping.Runner do
casing: product_data.casing,
condition: "new",
in_stock: product_data.in_stock,
subsonic: Map.get(product_data, :subsonic, false),
last_seen_at: now
}

View file

@ -7,7 +7,8 @@ defmodule Ammoprices.Scraping.ScrapeJob do
@scrapers [
Ammoprices.Scraping.Retailers.LuckyGunner,
Ammoprices.Scraping.Retailers.SgAmmo
Ammoprices.Scraping.Retailers.SgAmmo,
Ammoprices.Scraping.Retailers.TargetSportsUsa
]
@impl Oban.Worker

View file

@ -0,0 +1,20 @@
defmodule Ammoprices.Scraping.TextDetector do
@moduledoc false
def detect_subsonic(nil), do: false
def detect_subsonic(title), do: Regex.match?(~r/sub[\-\s]?sonic/i, title)
def detect_casing(nil), do: nil
def detect_casing(title) do
downcased = String.downcase(title)
cond do
String.contains?(downcased, "steel") -> "steel"
String.contains?(downcased, "aluminum") -> "aluminum"
String.contains?(downcased, "nickel") -> "brass"
String.contains?(downcased, "brass") -> "brass"
true -> nil
end
end
end

View file

@ -68,12 +68,8 @@ defmodule AmmopricesWeb.PriceComponents do
defp format_cpr(nil), do: "--"
defp format_cpr(cents) when cents < 100 do
"#{cents}¢/rd"
end
defp format_cpr(cents) do
dollars = cents / 100
"$#{:erlang.float_to_binary(dollars, decimals: 2)}/rd"
"$#{:erlang.float_to_binary(dollars, decimals: 2)}"
end
end

View file

@ -29,8 +29,12 @@ defmodule AmmopricesWeb.CaliberLive.Show do
page_title: caliber.name,
caliber: caliber,
in_stock_filter: true,
casing_filter: nil,
grain_weight_filter: nil,
subsonic_filter: nil,
chart_range: "30d"
)
|> load_filter_options()
|> load_products()
|> load_chart_data()
|> load_price_stats()
@ -48,6 +52,46 @@ defmodule AmmopricesWeb.CaliberLive.Show do
{:noreply, socket}
end
@impl true
def handle_event("filter_casing", %{"casing" => casing}, socket) do
current = socket.assigns.casing_filter
new_value = if current == casing, do: nil, else: casing
socket =
socket
|> assign(:casing_filter, new_value)
|> load_products()
{:noreply, socket}
end
@impl true
def handle_event("filter_grain_weight", %{"weight" => weight}, socket) do
current = socket.assigns.grain_weight_filter
weight_int = String.to_integer(weight)
new_value = if current == weight_int, do: nil, else: weight_int
socket =
socket
|> assign(:grain_weight_filter, new_value)
|> load_products()
{:noreply, socket}
end
@impl true
def handle_event("toggle_subsonic_filter", _params, socket) do
current = socket.assigns.subsonic_filter
new_value = if current == true, do: nil, else: true
socket =
socket
|> assign(:subsonic_filter, new_value)
|> load_products()
{:noreply, socket}
end
@impl true
def handle_event("change_range", %{"range" => range}, socket) do
socket =
@ -62,6 +106,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do
def handle_info({:prices_updated, _}, socket) do
socket =
socket
|> load_filter_options()
|> load_products()
|> load_chart_data()
|> load_price_stats()
@ -69,19 +114,32 @@ defmodule AmmopricesWeb.CaliberLive.Show do
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
end
defp load_products(socket) do
%{caliber: caliber, in_stock_filter: in_stock_filter} = socket.assigns
defp load_filter_options(socket) do
caliber_id = socket.assigns.caliber.id
opts =
if in_stock_filter do
[in_stock: true, limit: 200]
else
[limit: 200]
end
assign(socket,
available_casings: Catalog.distinct_casings_for_caliber(caliber_id),
available_grain_weights: Catalog.distinct_grain_weights_for_caliber(caliber_id)
)
end
defp load_products(socket) do
%{
caliber: caliber,
in_stock_filter: in_stock_filter,
casing_filter: casing_filter,
grain_weight_filter: grain_weight_filter,
subsonic_filter: subsonic_filter
} = socket.assigns
opts = [limit: 200]
opts = if in_stock_filter, do: Keyword.put(opts, :in_stock, true), else: opts
opts = if casing_filter, do: Keyword.put(opts, :casing, casing_filter), else: opts
opts = if grain_weight_filter, do: Keyword.put(opts, :grain_weight, grain_weight_filter), else: opts
opts = if subsonic_filter, do: Keyword.put(opts, :subsonic, true), else: opts
snapshots = Prices.latest_prices_for_caliber(caliber.id, opts)
# Preload product+retailer for each snapshot
product_ids = Enum.map(snapshots, & &1.product_id)
products_map =
@ -136,10 +194,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do
defp format_stat_cpr(nil), do: "--"
defp format_stat_cpr(cents) when cents < 100 do
"#{cents}\u00A2"
end
defp format_stat_cpr(cents) do
"$#{:erlang.float_to_binary(cents / 100, decimals: 2)}"
end
@ -235,7 +289,8 @@ defmodule AmmopricesWeb.CaliberLive.Show do
</div>
<%!-- Filters --%>
<div class="flex items-center gap-3">
<div id="filter-bar" class="flex flex-wrap items-center gap-2">
<%!-- In Stock toggle --%>
<button
id="filter-in-stock"
phx-click="toggle_stock_filter"
@ -252,6 +307,66 @@ defmodule AmmopricesWeb.CaliberLive.Show do
if(@in_stock_filter, do: "bg-success", else: "bg-base-content/30")
]} /> In Stock Only
</button>
<%!-- Subsonic toggle --%>
<button
id="filter-subsonic"
phx-click="toggle_subsonic_filter"
class={[
"flex items-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
if(@subsonic_filter,
do: "border-warning/40 bg-warning/10 text-warning",
else: "border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
)
]}
>
<span class={[
"inline-block w-1.5 h-1.5 rounded-full",
if(@subsonic_filter, do: "bg-warning", else: "bg-base-content/30")
]} /> Subsonic
</button>
<%!-- Casing filter buttons --%>
<%= if @available_casings != [] do %>
<span class="text-xs text-base-content/30 mx-1">|</span>
<button
:for={casing <- @available_casings}
id={"filter-casing-#{casing}"}
phx-click="filter_casing"
phx-value-casing={casing}
class={[
"px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer capitalize",
if(@casing_filter == casing,
do: "border-primary/40 bg-primary/10 text-primary",
else:
"border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
)
]}
>
{casing}
</button>
<% end %>
<%!-- Grain weight filter buttons --%>
<%= if @available_grain_weights != [] do %>
<span class="text-xs text-base-content/30 mx-1">|</span>
<button
:for={weight <- @available_grain_weights}
id={"filter-grain-#{weight}"}
phx-click="filter_grain_weight"
phx-value-weight={to_string(weight)}
class={[
"px-3 py-1.5 text-xs font-semibold rounded-lg border transition-all duration-150 cursor-pointer",
if(@grain_weight_filter == weight,
do: "border-primary/40 bg-primary/10 text-primary",
else:
"border-base-300 bg-base-100 text-base-content/60 hover:border-base-content/20"
)
]}
>
{weight}gr
</button>
<% end %>
</div>
<%!-- Product Table --%>
@ -271,6 +386,9 @@ defmodule AmmopricesWeb.CaliberLive.Show do
<th class="text-right px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden sm:table-cell">
Price
</th>
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
Grain
</th>
<th class="text-center px-3 py-2.5 font-semibold text-base-content/70 text-xs uppercase tracking-wider hidden md:table-cell">
Rounds
</th>
@ -289,16 +407,32 @@ defmodule AmmopricesWeb.CaliberLive.Show do
class="border-b border-base-300/50 hover:bg-base-200/30 transition-colors"
>
<td class="px-3 py-2.5">
<a
href={row.product.url}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-base-content hover:text-primary transition-colors line-clamp-2"
>
{row.product.title}
</a>
<div class="sm:hidden text-xs text-base-content/50 mt-0.5">
{row.product.retailer.name}
<div class="flex items-center gap-1.5">
<a
href={row.product.url}
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-base-content hover:text-primary transition-colors line-clamp-2"
>
{row.product.title}
</a>
<span
:if={row.product.subsonic}
class="shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded bg-warning/15 text-warning"
>
Sub
</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="sm:hidden text-xs text-base-content/50">
{row.product.retailer.name}
</span>
<span
:if={row.product.brand}
class="text-xs text-base-content/40"
>
{row.product.brand}
</span>
</div>
</td>
<td class="px-3 py-2.5 hidden sm:table-cell">
@ -315,6 +449,14 @@ defmodule AmmopricesWeb.CaliberLive.Show do
{"$#{:erlang.float_to_binary(row.price_cents / 100, decimals: 2)}"}
</span>
</td>
<td class="px-3 py-2.5 text-center hidden md:table-cell">
<span :if={row.product.grain_weight} class="text-xs text-base-content/60">
{row.product.grain_weight}gr
</span>
<span :if={!row.product.grain_weight} class="text-xs text-base-content/30">
</span>
</td>
<td class="px-3 py-2.5 text-center hidden md:table-cell">
<span class="text-xs text-base-content/60">{row.product.round_count || ""}</span>
</td>
@ -415,8 +557,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do
callbacks: {
label: function(ctx) {
const val = ctx.raw
if (val < 100) return ctx.dataset.label + ": " + val + "¢/rd"
return ctx.dataset.label + ": $" + (val / 100).toFixed(2) + "/rd"
return ctx.dataset.label + ": $" + (val / 100).toFixed(2)
}
}
}
@ -433,7 +574,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do
y: {
ticks: {
callback: function(val) {
if (val < 100) return val + "¢"
return "$" + (val / 100).toFixed(2)
},
font: { size: 10 }

View file

@ -0,0 +1,13 @@
defmodule Ammoprices.Repo.Migrations.AddSubsonicAndFilterIndexes do
use Ecto.Migration
def change do
alter table(:products) do
add :subsonic, :boolean, default: false, null: false
end
create index(:products, [:caliber_id, :casing])
create index(:products, [:caliber_id, :grain_weight])
create index(:products, [:caliber_id, :subsonic])
end
end

View file

@ -13,6 +13,11 @@ retailers = [
name: "SGAmmo",
slug: "sgammo",
base_url: "https://www.sgammo.com"
},
%{
name: "Target Sports USA",
slug: "target-sports-usa",
base_url: "https://www.targetsportsusa.com"
}
]

View file

@ -141,6 +141,51 @@ defmodule Ammoprices.CatalogTest do
assert product2.in_stock == false
end
test "upsert_product/3 stores subsonic flag" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 147gr Subsonic HP",
url: "/products/federal-subsonic",
brand: "Federal",
grain_weight: 147,
round_count: 50,
casing: "brass",
condition: "new",
in_stock: true,
subsonic: true,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, product} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert product.subsonic == true
end
test "upsert_product/3 updates subsonic on conflict" do
retailer = retailer_fixture()
caliber = caliber_fixture()
attrs = %{
title: "Federal 9mm 147gr HP",
url: "/products/federal-hp",
brand: "Federal",
grain_weight: 147,
round_count: 50,
casing: "brass",
in_stock: true,
subsonic: false,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, _} = Catalog.upsert_product(retailer.id, caliber.id, attrs)
assert {:ok, updated} =
Catalog.upsert_product(retailer.id, caliber.id, %{attrs | subsonic: true})
assert updated.subsonic == true
end
test "list_products_for_caliber/2 returns products for a caliber" do
caliber = caliber_fixture()
retailer = retailer_fixture()
@ -152,4 +197,56 @@ defmodule Ammoprices.CatalogTest do
assert hd(results).id == product.id
end
end
describe "distinct_grain_weights_for_caliber/1" do
test "returns sorted unique grain weights" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 124})
result = Catalog.distinct_grain_weights_for_caliber(caliber.id)
assert result == [115, 124, 147]
end
test "excludes nil grain weights" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: nil})
result = Catalog.distinct_grain_weights_for_caliber(caliber.id)
assert result == [115]
end
end
describe "distinct_casings_for_caliber/1" do
test "returns sorted unique casings" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: "aluminum"})
result = Catalog.distinct_casings_for_caliber(caliber.id)
assert result == ["aluminum", "brass", "steel"]
end
test "excludes nil casings" do
caliber = caliber_fixture()
retailer = retailer_fixture()
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
product_fixture(%{caliber: caliber, retailer: retailer, casing: nil})
result = Catalog.distinct_casings_for_caliber(caliber.id)
assert result == ["brass"]
end
end
end

View file

@ -73,6 +73,101 @@ defmodule Ammoprices.PricesTest do
assert hd(results).in_stock == true
end
test "filters by casing" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
brass = product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass"})
steel = product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel"})
snapshot_fixture(%{product: brass, price_per_round_cents: 28, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: steel, price_per_round_cents: 20, in_stock: true, recorded_at: now})
results = Prices.latest_prices_for_caliber(caliber.id, casing: "steel")
assert length(results) == 1
assert hd(results).price_per_round_cents == 20
end
test "filters by grain_weight" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
p115 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115})
p147 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147})
snapshot_fixture(%{product: p115, price_per_round_cents: 25, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: p147, price_per_round_cents: 40, in_stock: true, recorded_at: now})
results = Prices.latest_prices_for_caliber(caliber.id, grain_weight: 147)
assert length(results) == 1
assert hd(results).price_per_round_cents == 40
end
test "filters by subsonic" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
regular = product_fixture(%{caliber: caliber, retailer: retailer, subsonic: false})
sub = product_fixture(%{caliber: caliber, retailer: retailer, subsonic: true})
snapshot_fixture(%{product: regular, price_per_round_cents: 25, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: sub, price_per_round_cents: 50, in_stock: true, recorded_at: now})
results = Prices.latest_prices_for_caliber(caliber.id, subsonic: true)
assert length(results) == 1
assert hd(results).price_per_round_cents == 50
end
test "combines multiple filters" do
caliber = caliber_fixture()
retailer = retailer_fixture()
now = DateTime.truncate(DateTime.utc_now(), :second)
match =
product_fixture(%{
caliber: caliber,
retailer: retailer,
casing: "brass",
grain_weight: 147,
subsonic: true
})
no_match1 =
product_fixture(%{
caliber: caliber,
retailer: retailer,
casing: "steel",
grain_weight: 147,
subsonic: true
})
no_match2 =
product_fixture(%{
caliber: caliber,
retailer: retailer,
casing: "brass",
grain_weight: 115,
subsonic: false
})
snapshot_fixture(%{product: match, price_per_round_cents: 50, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: no_match1, price_per_round_cents: 30, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: no_match2, price_per_round_cents: 25, in_stock: true, recorded_at: now})
results =
Prices.latest_prices_for_caliber(caliber.id,
casing: "brass",
grain_weight: 147,
subsonic: true
)
assert length(results) == 1
assert hd(results).price_per_round_cents == 50
end
test "respects limit option" do
caliber = caliber_fixture()
retailer = retailer_fixture()

View file

@ -31,7 +31,7 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do
test "extracts all products from HTML", %{html: html} do
products = LuckyGunner.parse_products(html)
assert length(products) == 3
assert length(products) == 4
end
test "extracts product title", %{html: html} do
@ -88,5 +88,22 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do
[first | _] = LuckyGunner.parse_products(html)
assert first.in_stock == true
end
test "detects subsonic from title", %{html: html} do
products = LuckyGunner.parse_products(html)
# 4th product has "Subsonic" in title
assert Enum.at(products, 3).subsonic == true
end
test "non-subsonic products return false", %{html: html} do
[first | _] = LuckyGunner.parse_products(html)
assert first.subsonic == false
end
test "detects casing from description with TextDetector fallback", %{html: html} do
products = LuckyGunner.parse_products(html)
# 4th product: nickel-plated brass from description
assert Enum.at(products, 3).casing == "brass"
end
end
end

View file

@ -31,7 +31,7 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do
test "extracts all products from HTML", %{html: html} do
products = SgAmmo.parse_products(html)
assert length(products) == 3
assert length(products) == 4
end
test "extracts product title", %{html: html} do
@ -73,5 +73,22 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do
[first | _] = SgAmmo.parse_products(html)
assert first.in_stock == true
end
test "detects casing from title", %{html: html} do
products = SgAmmo.parse_products(html)
# 4th product has "Steel Case" in title
assert Enum.at(products, 3).casing == "steel"
end
test "detects subsonic from title", %{html: html} do
products = SgAmmo.parse_products(html)
# 4th product has "Subsonic" in title
assert Enum.at(products, 3).subsonic == true
end
test "non-subsonic products return false", %{html: html} do
[first | _] = SgAmmo.parse_products(html)
assert first.subsonic == false
end
end
end

View file

@ -0,0 +1,118 @@
defmodule Ammoprices.Scraping.Retailers.TargetSportsUsaTest do
use ExUnit.Case, async: true
alias Ammoprices.Scraping.Retailers.TargetSportsUsa
@fixture_path "test/fixtures/target_sports_usa/9mm.html"
describe "retailer_slug/0" do
test "returns target-sports-usa" do
assert TargetSportsUsa.retailer_slug() == "target-sports-usa"
end
end
describe "category_url/1" do
test "maps 9mm-luger caliber to correct URL" do
caliber = %{slug: "9mm-luger", category: "handgun"}
assert TargetSportsUsa.category_url(caliber) == "/9mm-luger-ammo-c-51.aspx"
end
test "maps 300-blackout caliber to correct URL" do
caliber = %{slug: "300-blackout", category: "rifle"}
assert TargetSportsUsa.category_url(caliber) == "/300-aac-blackout-ammo-c-969.aspx"
end
test "returns nil for unmapped caliber" do
caliber = %{slug: "unknown", category: "handgun"}
assert TargetSportsUsa.category_url(caliber) == nil
end
end
describe "parse_products/1" do
setup do
html = File.read!(@fixture_path)
%{html: html}
end
test "extracts all products from HTML", %{html: html} do
products = TargetSportsUsa.parse_products(html)
assert length(products) == 4
end
test "extracts product title", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.title =~ "Federal Champion 9mm Ammo 115 Grain Full Metal Jacket"
end
test "extracts product URL", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.url == "/federal-champion-9mm-ammo-115-grain-fmj-wm5199-p-58432.aspx"
end
test "extracts price in cents", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.price_cents == 1275
end
test "extracts price per round in cents", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.price_per_round_cents == 26
end
test "extracts brand from h2 strong", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.brand == "Federal"
end
test "strips trailing Ammunition/Ammo from brand", %{html: html} do
products = TargetSportsUsa.parse_products(html)
# "Winchester Ammo" -> "Winchester"
assert Enum.at(products, 1).brand == "Winchester"
# "Tula Ammo" -> "Tula"
assert Enum.at(products, 3).brand == "Tula"
end
test "extracts grain weight from title", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.grain_weight == 115
end
test "extracts round count from title", %{html: html} do
products = TargetSportsUsa.parse_products(html)
# Second product: "50 Round Box"
assert Enum.at(products, 1).round_count == 50
end
test "marks in-stock products", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.in_stock == true
end
test "marks out-of-stock products", %{html: html} do
products = TargetSportsUsa.parse_products(html)
assert Enum.at(products, 3).in_stock == false
end
test "detects subsonic from title", %{html: html} do
products = TargetSportsUsa.parse_products(html)
# 3rd product has "Subsonic" in title
assert Enum.at(products, 2).subsonic == true
end
test "non-subsonic products return false", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.subsonic == false
end
test "detects casing from title", %{html: html} do
products = TargetSportsUsa.parse_products(html)
# 4th product has "Steel Case" in title
assert Enum.at(products, 3).casing == "steel"
end
test "returns nil casing when not detected", %{html: html} do
[first | _] = TargetSportsUsa.parse_products(html)
assert first.casing == nil
end
end
end

View file

@ -25,11 +25,11 @@ defmodule Ammoprices.Scraping.RunnerTest do
scraper = LuckyGunner
assert {:ok, stats} = Runner.run(scraper, caliber)
assert stats.products_count == 3
assert stats.snapshots_count == 3
assert stats.products_count == 4
assert stats.snapshots_count == 4
products = Catalog.list_products_for_caliber(caliber.id)
assert length(products) == 3
assert length(products) == 4
end
test "updates retailer last_scraped_at", %{caliber: caliber} do

View file

@ -11,6 +11,13 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
setup do
retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
retailer_fixture(%{name: "SGAmmo", slug: "sgammo", base_url: "https://www.sgammo.com"})
retailer_fixture(%{
name: "Target Sports USA",
slug: "target-sports-usa",
base_url: "https://www.targetsportsusa.com"
})
caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->

View file

@ -0,0 +1,69 @@
defmodule Ammoprices.Scraping.TextDetectorTest do
use ExUnit.Case, async: true
alias Ammoprices.Scraping.TextDetector
describe "detect_subsonic/1" do
test "detects 'subsonic' in title" do
assert TextDetector.detect_subsonic("Federal 9mm 147gr Subsonic HP") == true
end
test "detects 'sub-sonic' in title" do
assert TextDetector.detect_subsonic("Federal 9mm Sub-Sonic 147gr") == true
end
test "detects 'sub sonic' in title" do
assert TextDetector.detect_subsonic("Federal 9mm Sub Sonic 147gr") == true
end
test "is case-insensitive" do
assert TextDetector.detect_subsonic("Federal 9mm SUBSONIC 147gr") == true
end
test "returns false for non-subsonic title" do
assert TextDetector.detect_subsonic("Federal 9mm 115gr FMJ") == false
end
test "returns false for nil" do
assert TextDetector.detect_subsonic(nil) == false
end
end
describe "detect_casing/1" do
test "detects steel case" do
assert TextDetector.detect_casing("Wolf 9mm 115gr FMJ Steel Case") == "steel"
end
test "detects steel cased" do
assert TextDetector.detect_casing("Wolf 9mm 115gr FMJ Steel Cased") == "steel"
end
test "detects brass case" do
assert TextDetector.detect_casing("Blazer Brass 9mm 115gr FMJ") == "brass"
end
test "detects brass cased" do
assert TextDetector.detect_casing("Federal 9mm Brass Cased 115gr") == "brass"
end
test "detects aluminum case" do
assert TextDetector.detect_casing("CCI Aluminum Case 9mm 115gr") == "aluminum"
end
test "detects nickel plated as brass" do
assert TextDetector.detect_casing("Speer 9mm Nickel Plated 124gr") == "brass"
end
test "returns nil for no match" do
assert TextDetector.detect_casing("Federal 9mm 115gr FMJ") == nil
end
test "returns nil for nil input" do
assert TextDetector.detect_casing(nil) == nil
end
test "is case-insensitive" do
assert TextDetector.detect_casing("Wolf 9mm STEEL CASE 115gr") == "steel"
end
end
end

View file

@ -89,5 +89,104 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do
assert view |> element("#range-7d") |> render_click()
assert has_element?(view, "#price-chart-container")
end
test "renders casing filter buttons", %{conn: conn, caliber: caliber, retailer: retailer} do
now = DateTime.truncate(DateTime.utc_now(), :second)
brass = product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass", in_stock: true})
steel = product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel", in_stock: true})
snapshot_fixture(%{product: brass, price_per_round_cents: 28, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: steel, price_per_round_cents: 20, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#filter-casing-brass")
assert has_element?(view, "#filter-casing-steel")
end
test "renders grain weight filter buttons", %{conn: conn, caliber: caliber, retailer: retailer} do
now = DateTime.truncate(DateTime.utc_now(), :second)
p115 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 115, in_stock: true})
p147 = product_fixture(%{caliber: caliber, retailer: retailer, grain_weight: 147, in_stock: true})
snapshot_fixture(%{product: p115, price_per_round_cents: 25, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: p147, price_per_round_cents: 40, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#filter-grain-115")
assert has_element?(view, "#filter-grain-147")
end
test "renders subsonic filter toggle", %{conn: conn, caliber: caliber, retailer: retailer} do
now = DateTime.truncate(DateTime.utc_now(), :second)
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
snapshot_fixture(%{product: product, price_per_round_cents: 25, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#filter-subsonic")
end
test "displays grain weight and brand in product table", %{conn: conn, caliber: caliber, retailer: retailer} do
now = DateTime.truncate(DateTime.utc_now(), :second)
product =
product_fixture(%{
caliber: caliber,
retailer: retailer,
grain_weight: 115,
brand: "Federal",
in_stock: true
})
snapshot_fixture(%{product: product, price_per_round_cents: 28, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
assert has_element?(view, "#products [id]")
end
test "filters by casing when clicked", %{conn: conn, caliber: caliber, retailer: retailer} do
now = DateTime.truncate(DateTime.utc_now(), :second)
brass =
product_fixture(%{caliber: caliber, retailer: retailer, casing: "brass", in_stock: true, title: "Brass Ammo"})
steel =
product_fixture(%{caliber: caliber, retailer: retailer, casing: "steel", in_stock: true, title: "Steel Ammo"})
snapshot_fixture(%{product: brass, price_per_round_cents: 28, in_stock: true, recorded_at: now})
snapshot_fixture(%{product: steel, price_per_round_cents: 20, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
# Click steel filter
view |> element("#filter-casing-steel") |> render_click()
# Should still have the filter button active
assert has_element?(view, "#filter-casing-steel")
end
test "receives real-time price updates via PubSub", %{conn: conn, caliber: caliber, retailer: retailer} do
now = DateTime.truncate(DateTime.utc_now(), :second)
product = product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true})
snapshot_fixture(%{product: product, price_per_round_cents: 30, in_stock: true, recorded_at: now})
{:ok, view, _html} = live(conn, "/calibers/#{caliber.slug}")
# Add a new product + snapshot
new_product =
product_fixture(%{caliber: caliber, retailer: retailer, in_stock: true, title: "New Product", casing: "brass"})
snapshot_fixture(%{product: new_product, price_per_round_cents: 18, in_stock: true, recorded_at: now})
# Broadcast price update
Phoenix.PubSub.broadcast(Ammoprices.PubSub, "prices:updated", {:prices_updated, %{}})
# The view should re-render with updated data
html = render(view)
assert html =~ "New Product"
end
end
end

View file

@ -100,4 +100,38 @@
</div>
</div>
</li>
<li class="item">
<a href="https://www.luckygunner.com/9mm-147-grain-jhp-federal-hst-subsonic-50-rounds" class="product-image">
<img src="https://cdn-secure.luckygunner.com/media/catalog/product/federal-hst-9mm.jpg" alt="Federal HST 9mm 147gr JHP Subsonic">
</a>
<div class="product-shop">
<h3 class="product-name">
<a href="https://www.luckygunner.com/9mm-147-grain-jhp-federal-hst-subsonic-50-rounds">
<span>9mm - 147 Grain JHP - Federal HST Subsonic - 50 Rounds</span>
</a>
</h3>
<div class="col2-set">
<div class="col-1">
<div class="price-box">
<span class="regular-price">$31.00</span>
</div>
</div>
<div class="col-2">
<p class="cprc">62¢ per round</p>
<p class="availability availability-simple">
<span class="stock-qty">35 </span><span class="in-stock">In Stock</span>
</p>
</div>
</div>
<div class="desc std">
<ul>
<li>Quantity - 50 rounds per box</li>
<li>Manufacturer - Federal</li>
<li>Bullets - 147 grain jacketed hollow point (JHP)</li>
<li>Casings - Boxer-primed nickel-plated brass</li>
</ul>
</div>
</div>
</li>
</ul>

View file

@ -72,5 +72,28 @@
<span>(<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>0.28</bdi></span> Per Round)</span>
</td>
</tr>
<tr class="sgammo-content-product-item product instock product_cat-9mm-luger-ammo">
<td class="sgammo-content-product-item__col-thumbnail">
<img src="https://sgammo.com/wp-content/uploads/tula-9mm-subsonic.jpg" alt="">
</td>
<td class="sgammo-content-product-item__col-title">
<h4 class="sgammo-content-product-item__title">
<a href="https://sgammo.com/product/9mm-luger-ammo/50-round-box-9mm-luger-subsonic-147-grain-fmj-steel-case-ammo-by-tula/">
50 Round Box - 9mm Luger Subsonic 147 Grain FMJ Steel Case Ammo by Tula
</a>
</h4>
<div>SKU: TUL-9SUB</div>
</td>
<td class="sgammo-content-product-item__col-qty">
80+
</td>
<td class="sgammo-content-product-item__col-price">
<strong class="sgammo-cl-mb025">
<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>11.95</bdi></span> Each
</strong>
<span>(<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>0.24</bdi></span> Per Round)</span>
</td>
</tr>
</tbody>
</table>

View file

@ -0,0 +1,61 @@
<ul class="product-list">
<li>
<a href="/federal-champion-9mm-ammo-115-grain-fmj-wm5199-p-58432.aspx">
<img src="/images/products/federal-9mm-115.jpg" alt="Federal Champion 9mm">
</a>
<h2>
<strong>Federal Ammunition</strong>
Federal Champion 9mm Ammo 115 Grain Full Metal Jacket - WM5199
</h2>
<div class="product-listing-price">
$12.75
<span>$0.255 Per Round</span>
</div>
<div class="add-to-cart">Add To Cart</div>
</li>
<li>
<a href="/winchester-white-box-9mm-ammo-124-grain-fmj-usa9mm-p-4523.aspx">
<img src="/images/products/winchester-9mm-124.jpg" alt="Winchester White Box 9mm">
</a>
<h2>
<strong>Winchester Ammo</strong>
Winchester White Box 9mm Ammo 124 Grain Full Metal Jacket 50 Round Box - USA9MM
</h2>
<div class="product-listing-price">
$14.50
<span>$0.29 Per Round</span>
</div>
<div class="add-to-cart">Add To Cart</div>
</li>
<li>
<a href="/federal-hst-9mm-ammo-147-grain-jhp-subsonic-p-58790.aspx">
<img src="/images/products/federal-hst-9mm.jpg" alt="Federal HST 9mm Subsonic">
</a>
<h2>
<strong>Federal Ammunition</strong>
Federal HST 9mm Ammo 147 Grain Jacketed Hollow Point Subsonic 50 Round Box
</h2>
<div class="product-listing-price">
$30.99
<span>$0.62 Per Round</span>
</div>
<div class="add-to-cart">Add To Cart</div>
</li>
<li>
<a href="/tula-9mm-ammo-115-grain-fmj-steel-case-ta919150-p-71234.aspx">
<img src="/images/products/tula-9mm-steel.jpg" alt="Tula 9mm Steel Case">
</a>
<h2>
<strong>Tula Ammo</strong>
Tula 9mm Ammo 115 Grain FMJ Steel Case 50 Round Box - TA919150
</h2>
<div class="product-listing-price">
$9.99
<span>$0.20 Per Round</span>
</div>
<div class="add-to-cart">Out of Stock</div>
</li>
</ul>

View file

@ -63,6 +63,7 @@ defmodule Ammoprices.Fixtures do
casing: "brass",
condition: "new",
in_stock: true,
subsonic: false,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
},
Map.drop(attrs, [:retailer, :caliber])