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:
parent
bbe5bde145
commit
16a28ac01c
26 changed files with 1105 additions and 41 deletions
|
|
@ -67,7 +67,18 @@ defmodule Ammoprices.Catalog do
|
||||||
|> Repo.insert(
|
|> Repo.insert(
|
||||||
on_conflict:
|
on_conflict:
|
||||||
{:replace,
|
{: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],
|
conflict_target: [:retailer_id, :url],
|
||||||
returning: true
|
returning: true
|
||||||
)
|
)
|
||||||
|
|
@ -81,4 +92,24 @@ defmodule Ammoprices.Catalog do
|
||||||
|> limit(^limit)
|
|> limit(^limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ defmodule Ammoprices.Catalog.Product do
|
||||||
field :upc, :string
|
field :upc, :string
|
||||||
field :external_id, :string
|
field :external_id, :string
|
||||||
field :in_stock, :boolean, default: true
|
field :in_stock, :boolean, default: true
|
||||||
|
field :subsonic, :boolean, default: false
|
||||||
field :last_seen_at, :utc_datetime
|
field :last_seen_at, :utc_datetime
|
||||||
|
|
||||||
has_many :price_snapshots, Ammoprices.Prices.PriceSnapshot
|
has_many :price_snapshots, Ammoprices.Prices.PriceSnapshot
|
||||||
|
|
@ -40,6 +41,7 @@ defmodule Ammoprices.Catalog.Product do
|
||||||
:upc,
|
:upc,
|
||||||
:external_id,
|
:external_id,
|
||||||
:in_stock,
|
:in_stock,
|
||||||
|
:subsonic,
|
||||||
:last_seen_at
|
:last_seen_at
|
||||||
])
|
])
|
||||||
|> validate_required([:title, :url])
|
|> validate_required([:title, :url])
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ defmodule Ammoprices.Prices do
|
||||||
def latest_prices_for_caliber(caliber_id, opts \\ []) do
|
def latest_prices_for_caliber(caliber_id, opts \\ []) do
|
||||||
limit = Keyword.get(opts, :limit, 100)
|
limit = Keyword.get(opts, :limit, 100)
|
||||||
in_stock = Keyword.get(opts, :in_stock)
|
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 =
|
latest_per_product =
|
||||||
from(ps in PriceSnapshot,
|
from(ps in PriceSnapshot,
|
||||||
|
|
@ -30,6 +33,9 @@ defmodule Ammoprices.Prices do
|
||||||
distinct: ps.product_id,
|
distinct: ps.product_id,
|
||||||
order_by: [asc: ps.product_id, desc: ps.recorded_at]
|
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 =
|
query =
|
||||||
from(ps in subquery(latest_per_product),
|
from(ps in subquery(latest_per_product),
|
||||||
|
|
@ -47,6 +53,20 @@ defmodule Ammoprices.Prices do
|
||||||
Repo.all(query)
|
Repo.all(query)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Returns daily aggregates (avg, min, max) of price_per_round_cents
|
Returns daily aggregates (avg, min, max) of price_per_round_cents
|
||||||
for in-stock products of a caliber.
|
for in-stock products of a caliber.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunner do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
@behaviour Ammoprices.Scraping.Scraper
|
@behaviour Ammoprices.Scraping.Scraper
|
||||||
|
|
||||||
|
alias Ammoprices.Scraping.TextDetector
|
||||||
|
|
||||||
@slug_to_path %{
|
@slug_to_path %{
|
||||||
"9mm-luger" => "/handgun/9mm-ammo",
|
"9mm-luger" => "/handgun/9mm-ammo",
|
||||||
"45-acp" => "/handgun/45-acp-ammo",
|
"45-acp" => "/handgun/45-acp-ammo",
|
||||||
|
|
@ -55,7 +57,8 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunner do
|
||||||
brand: extract_brand(desc_items),
|
brand: extract_brand(desc_items),
|
||||||
grain_weight: extract_grain_weight(desc_items),
|
grain_weight: extract_grain_weight(desc_items),
|
||||||
round_count: extract_round_count(title),
|
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)
|
in_stock: extract_in_stock(item)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmo do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
@behaviour Ammoprices.Scraping.Scraper
|
@behaviour Ammoprices.Scraping.Scraper
|
||||||
|
|
||||||
|
alias Ammoprices.Scraping.TextDetector
|
||||||
|
|
||||||
@slug_to_path %{
|
@slug_to_path %{
|
||||||
"9mm-luger" => "/catalog/pistol-ammo-for-sale/9mm-luger-ammo",
|
"9mm-luger" => "/catalog/pistol-ammo-for-sale/9mm-luger-ammo",
|
||||||
"45-acp" => "/catalog/pistol-ammo-for-sale/45-auto-acp-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),
|
brand: extract_brand(title),
|
||||||
grain_weight: extract_grain_weight(title),
|
grain_weight: extract_grain_weight(title),
|
||||||
round_count: extract_round_count(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)
|
in_stock: extract_in_stock(row)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
|
||||||
191
lib/ammoprices/scraping/retailers/target_sports_usa.ex
Normal file
191
lib/ammoprices/scraping/retailers/target_sports_usa.ex
Normal 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
|
||||||
|
|
@ -35,6 +35,7 @@ defmodule Ammoprices.Scraping.Runner do
|
||||||
casing: product_data.casing,
|
casing: product_data.casing,
|
||||||
condition: "new",
|
condition: "new",
|
||||||
in_stock: product_data.in_stock,
|
in_stock: product_data.in_stock,
|
||||||
|
subsonic: Map.get(product_data, :subsonic, false),
|
||||||
last_seen_at: now
|
last_seen_at: now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ defmodule Ammoprices.Scraping.ScrapeJob do
|
||||||
|
|
||||||
@scrapers [
|
@scrapers [
|
||||||
Ammoprices.Scraping.Retailers.LuckyGunner,
|
Ammoprices.Scraping.Retailers.LuckyGunner,
|
||||||
Ammoprices.Scraping.Retailers.SgAmmo
|
Ammoprices.Scraping.Retailers.SgAmmo,
|
||||||
|
Ammoprices.Scraping.Retailers.TargetSportsUsa
|
||||||
]
|
]
|
||||||
|
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
|
|
|
||||||
20
lib/ammoprices/scraping/text_detector.ex
Normal file
20
lib/ammoprices/scraping/text_detector.ex
Normal 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
|
||||||
|
|
@ -68,12 +68,8 @@ defmodule AmmopricesWeb.PriceComponents do
|
||||||
|
|
||||||
defp format_cpr(nil), do: "--"
|
defp format_cpr(nil), do: "--"
|
||||||
|
|
||||||
defp format_cpr(cents) when cents < 100 do
|
|
||||||
"#{cents}¢/rd"
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_cpr(cents) do
|
defp format_cpr(cents) do
|
||||||
dollars = cents / 100
|
dollars = cents / 100
|
||||||
"$#{:erlang.float_to_binary(dollars, decimals: 2)}/rd"
|
"$#{:erlang.float_to_binary(dollars, decimals: 2)}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,12 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
page_title: caliber.name,
|
page_title: caliber.name,
|
||||||
caliber: caliber,
|
caliber: caliber,
|
||||||
in_stock_filter: true,
|
in_stock_filter: true,
|
||||||
|
casing_filter: nil,
|
||||||
|
grain_weight_filter: nil,
|
||||||
|
subsonic_filter: nil,
|
||||||
chart_range: "30d"
|
chart_range: "30d"
|
||||||
)
|
)
|
||||||
|
|> load_filter_options()
|
||||||
|> load_products()
|
|> load_products()
|
||||||
|> load_chart_data()
|
|> load_chart_data()
|
||||||
|> load_price_stats()
|
|> load_price_stats()
|
||||||
|
|
@ -48,6 +52,46 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("change_range", %{"range" => range}, socket) do
|
def handle_event("change_range", %{"range" => range}, socket) do
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -62,6 +106,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
def handle_info({:prices_updated, _}, socket) do
|
def handle_info({:prices_updated, _}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|> load_filter_options()
|
||||||
|> load_products()
|
|> load_products()
|
||||||
|> load_chart_data()
|
|> load_chart_data()
|
||||||
|> load_price_stats()
|
|> load_price_stats()
|
||||||
|
|
@ -69,19 +114,32 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
|
{:noreply, push_event(socket, "update-chart", %{data: socket.assigns.chart_data})}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_products(socket) do
|
defp load_filter_options(socket) do
|
||||||
%{caliber: caliber, in_stock_filter: in_stock_filter} = socket.assigns
|
caliber_id = socket.assigns.caliber.id
|
||||||
|
|
||||||
opts =
|
assign(socket,
|
||||||
if in_stock_filter do
|
available_casings: Catalog.distinct_casings_for_caliber(caliber_id),
|
||||||
[in_stock: true, limit: 200]
|
available_grain_weights: Catalog.distinct_grain_weights_for_caliber(caliber_id)
|
||||||
else
|
)
|
||||||
[limit: 200]
|
|
||||||
end
|
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)
|
snapshots = Prices.latest_prices_for_caliber(caliber.id, opts)
|
||||||
|
|
||||||
# Preload product+retailer for each snapshot
|
|
||||||
product_ids = Enum.map(snapshots, & &1.product_id)
|
product_ids = Enum.map(snapshots, & &1.product_id)
|
||||||
|
|
||||||
products_map =
|
products_map =
|
||||||
|
|
@ -136,10 +194,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
|
|
||||||
defp format_stat_cpr(nil), 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
|
defp format_stat_cpr(cents) do
|
||||||
"$#{:erlang.float_to_binary(cents / 100, decimals: 2)}"
|
"$#{:erlang.float_to_binary(cents / 100, decimals: 2)}"
|
||||||
end
|
end
|
||||||
|
|
@ -235,7 +289,8 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Filters --%>
|
<%!-- Filters --%>
|
||||||
<div class="flex items-center gap-3">
|
<div id="filter-bar" class="flex flex-wrap items-center gap-2">
|
||||||
|
<%!-- In Stock toggle --%>
|
||||||
<button
|
<button
|
||||||
id="filter-in-stock"
|
id="filter-in-stock"
|
||||||
phx-click="toggle_stock_filter"
|
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")
|
if(@in_stock_filter, do: "bg-success", else: "bg-base-content/30")
|
||||||
]} /> In Stock Only
|
]} /> In Stock Only
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<%!-- Product Table --%>
|
<%!-- 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">
|
<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
|
Price
|
||||||
</th>
|
</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">
|
<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
|
Rounds
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -289,6 +407,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
class="border-b border-base-300/50 hover:bg-base-200/30 transition-colors"
|
class="border-b border-base-300/50 hover:bg-base-200/30 transition-colors"
|
||||||
>
|
>
|
||||||
<td class="px-3 py-2.5">
|
<td class="px-3 py-2.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
<a
|
<a
|
||||||
href={row.product.url}
|
href={row.product.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -297,8 +416,23 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
>
|
>
|
||||||
{row.product.title}
|
{row.product.title}
|
||||||
</a>
|
</a>
|
||||||
<div class="sm:hidden text-xs text-base-content/50 mt-0.5">
|
<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}
|
{row.product.retailer.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:if={row.product.brand}
|
||||||
|
class="text-xs text-base-content/40"
|
||||||
|
>
|
||||||
|
{row.product.brand}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2.5 hidden sm:table-cell">
|
<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)}"}
|
{"$#{:erlang.float_to_binary(row.price_cents / 100, decimals: 2)}"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<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>
|
<span class="text-xs text-base-content/60">{row.product.round_count || "—"}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -415,8 +557,7 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(ctx) {
|
label: function(ctx) {
|
||||||
const val = ctx.raw
|
const val = ctx.raw
|
||||||
if (val < 100) return ctx.dataset.label + ": " + val + "¢/rd"
|
return ctx.dataset.label + ": $" + (val / 100).toFixed(2)
|
||||||
return ctx.dataset.label + ": $" + (val / 100).toFixed(2) + "/rd"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +574,6 @@ defmodule AmmopricesWeb.CaliberLive.Show do
|
||||||
y: {
|
y: {
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: function(val) {
|
callback: function(val) {
|
||||||
if (val < 100) return val + "¢"
|
|
||||||
return "$" + (val / 100).toFixed(2)
|
return "$" + (val / 100).toFixed(2)
|
||||||
},
|
},
|
||||||
font: { size: 10 }
|
font: { size: 10 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -13,6 +13,11 @@ retailers = [
|
||||||
name: "SGAmmo",
|
name: "SGAmmo",
|
||||||
slug: "sgammo",
|
slug: "sgammo",
|
||||||
base_url: "https://www.sgammo.com"
|
base_url: "https://www.sgammo.com"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Target Sports USA",
|
||||||
|
slug: "target-sports-usa",
|
||||||
|
base_url: "https://www.targetsportsusa.com"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,51 @@ defmodule Ammoprices.CatalogTest do
|
||||||
assert product2.in_stock == false
|
assert product2.in_stock == false
|
||||||
end
|
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
|
test "list_products_for_caliber/2 returns products for a caliber" do
|
||||||
caliber = caliber_fixture()
|
caliber = caliber_fixture()
|
||||||
retailer = retailer_fixture()
|
retailer = retailer_fixture()
|
||||||
|
|
@ -152,4 +197,56 @@ defmodule Ammoprices.CatalogTest do
|
||||||
assert hd(results).id == product.id
|
assert hd(results).id == product.id
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,101 @@ defmodule Ammoprices.PricesTest do
|
||||||
assert hd(results).in_stock == true
|
assert hd(results).in_stock == true
|
||||||
end
|
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
|
test "respects limit option" do
|
||||||
caliber = caliber_fixture()
|
caliber = caliber_fixture()
|
||||||
retailer = retailer_fixture()
|
retailer = retailer_fixture()
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do
|
||||||
|
|
||||||
test "extracts all products from HTML", %{html: html} do
|
test "extracts all products from HTML", %{html: html} do
|
||||||
products = LuckyGunner.parse_products(html)
|
products = LuckyGunner.parse_products(html)
|
||||||
assert length(products) == 3
|
assert length(products) == 4
|
||||||
end
|
end
|
||||||
|
|
||||||
test "extracts product title", %{html: html} do
|
test "extracts product title", %{html: html} do
|
||||||
|
|
@ -88,5 +88,22 @@ defmodule Ammoprices.Scraping.Retailers.LuckyGunnerTest do
|
||||||
[first | _] = LuckyGunner.parse_products(html)
|
[first | _] = LuckyGunner.parse_products(html)
|
||||||
assert first.in_stock == true
|
assert first.in_stock == true
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do
|
||||||
|
|
||||||
test "extracts all products from HTML", %{html: html} do
|
test "extracts all products from HTML", %{html: html} do
|
||||||
products = SgAmmo.parse_products(html)
|
products = SgAmmo.parse_products(html)
|
||||||
assert length(products) == 3
|
assert length(products) == 4
|
||||||
end
|
end
|
||||||
|
|
||||||
test "extracts product title", %{html: html} do
|
test "extracts product title", %{html: html} do
|
||||||
|
|
@ -73,5 +73,22 @@ defmodule Ammoprices.Scraping.Retailers.SgAmmoTest do
|
||||||
[first | _] = SgAmmo.parse_products(html)
|
[first | _] = SgAmmo.parse_products(html)
|
||||||
assert first.in_stock == true
|
assert first.in_stock == true
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
118
test/ammoprices/scraping/retailers/target_sports_usa_test.exs
Normal file
118
test/ammoprices/scraping/retailers/target_sports_usa_test.exs
Normal 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
|
||||||
|
|
@ -25,11 +25,11 @@ defmodule Ammoprices.Scraping.RunnerTest do
|
||||||
scraper = LuckyGunner
|
scraper = LuckyGunner
|
||||||
|
|
||||||
assert {:ok, stats} = Runner.run(scraper, caliber)
|
assert {:ok, stats} = Runner.run(scraper, caliber)
|
||||||
assert stats.products_count == 3
|
assert stats.products_count == 4
|
||||||
assert stats.snapshots_count == 3
|
assert stats.snapshots_count == 4
|
||||||
|
|
||||||
products = Catalog.list_products_for_caliber(caliber.id)
|
products = Catalog.list_products_for_caliber(caliber.id)
|
||||||
assert length(products) == 3
|
assert length(products) == 4
|
||||||
end
|
end
|
||||||
|
|
||||||
test "updates retailer last_scraped_at", %{caliber: caliber} do
|
test "updates retailer last_scraped_at", %{caliber: caliber} do
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ defmodule Ammoprices.Scraping.ScrapeJobTest do
|
||||||
setup do
|
setup do
|
||||||
retailer_fixture(%{name: "Lucky Gunner", slug: "lucky-gunner", base_url: "https://www.luckygunner.com"})
|
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: "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"]})
|
caliber_fixture(%{name: "9mm Luger", slug: "9mm-luger", category: "handgun", aliases: ["9mm", "9x19"]})
|
||||||
|
|
||||||
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->
|
Req.Test.stub(Ammoprices.Scraping.HttpClient, fn conn ->
|
||||||
|
|
|
||||||
69
test/ammoprices/scraping/text_detector_test.exs
Normal file
69
test/ammoprices/scraping/text_detector_test.exs
Normal 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
|
||||||
|
|
@ -89,5 +89,104 @@ defmodule AmmopricesWeb.CaliberLive.ShowTest do
|
||||||
assert view |> element("#range-7d") |> render_click()
|
assert view |> element("#range-7d") |> render_click()
|
||||||
assert has_element?(view, "#price-chart-container")
|
assert has_element?(view, "#price-chart-container")
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
34
test/fixtures/lucky_gunner/9mm.html
vendored
34
test/fixtures/lucky_gunner/9mm.html
vendored
|
|
@ -100,4 +100,38 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
|
||||||
23
test/fixtures/sg_ammo/9mm.html
vendored
23
test/fixtures/sg_ammo/9mm.html
vendored
|
|
@ -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>
|
<span>(<span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">$</span>0.28</bdi></span> Per Round)</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
61
test/fixtures/target_sports_usa/9mm.html
vendored
Normal file
61
test/fixtures/target_sports_usa/9mm.html
vendored
Normal 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>
|
||||||
|
|
@ -63,6 +63,7 @@ defmodule Ammoprices.Fixtures do
|
||||||
casing: "brass",
|
casing: "brass",
|
||||||
condition: "new",
|
condition: "new",
|
||||||
in_stock: true,
|
in_stock: true,
|
||||||
|
subsonic: false,
|
||||||
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
},
|
},
|
||||||
Map.drop(attrs, [:retailer, :caliber])
|
Map.drop(attrs, [:retailer, :caliber])
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue