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(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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,
|
||||
condition: "new",
|
||||
in_stock: product_data.in_stock,
|
||||
subsonic: Map.get(product_data, :subsonic, false),
|
||||
last_seen_at: now
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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(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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
slug: "sgammo",
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
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 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
|
||||
|
|
|
|||
34
test/fixtures/lucky_gunner/9mm.html
vendored
34
test/fixtures/lucky_gunner/9mm.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
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>
|
||||
</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>
|
||||
|
|
|
|||
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",
|
||||
condition: "new",
|
||||
in_stock: true,
|
||||
subsonic: false,
|
||||
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
|
||||
},
|
||||
Map.drop(attrs, [:retailer, :caliber])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue