- Fix Oban self-scheduling chain silently dying (remove unique constraint so after block can schedule next job)
- Fix Natchez and TrueShotAmmo extract_price defaulting to 0 for missing price data
- Fix caliber_matcher redundant String.downcase inside Enum.find (pre-downcase outside loop)
- Fix runner.ex double iteration (Enum.map + Enum.count merged into single Enum.reduce)
- Fix price_stats_for_caliber firing 3 queries instead of 2 (merge all_time + thirty_day via FILTER)
- Fix PSA duplicate Floki.find on same selector (merge extract_title + extract_url)
- Fix PubSub broadcast carrying no caliber IDs (now includes IDs; clients skip irrelevant reloads)
- Fix BulkAmmo ^ anchor skipping non-leading round counts
- Fix TargetSportsUSA fragile exact text match 'Add To Cart' (use case-insensitive contains)
- Fix SgAmmo dead destructure {_, _, _} = row
- Fix text_detector brass/nickel detection order
- Fix Natchez GraphQL string interpolation (add escape_gql_string)
- Fix chart data triple Enum.map merged into single reduce
- Change Oban scraping queue workers from 2 to 1 (only one needed with Process.sleep)
141 lines
3.7 KiB
Elixir
141 lines
3.7 KiB
Elixir
defmodule Ammoprices.Scraping.Retailers.PalmettoStateArmory do
|
|
@moduledoc false
|
|
@behaviour Ammoprices.Scraping.Scraper
|
|
|
|
alias Ammoprices.Scraping.TextDetector
|
|
|
|
@slug_to_path %{
|
|
"9mm-luger" => "/9mm-ammo.html",
|
|
"45-acp" => "/45-acp-ammo.html",
|
|
"380-acp" => "/380-acp-ammo.html",
|
|
"40-sw" => "/40-s-w-ammo.html",
|
|
"38-special" => "/38-special-ammo.html",
|
|
"357-magnum" => "/357-magnum-ammo.html",
|
|
"10mm-auto" => "/10mm-auto-ammo.html",
|
|
"223-rem" => "/223-ammo.html",
|
|
"556-nato" => "/5-56-ammo.html",
|
|
"308-win" => "/308-winchester-ammo.html",
|
|
"762x39" => "/7-62x39mm-ammo.html",
|
|
"30-06" => "/30-06-springfield-ammo.html",
|
|
"300-blackout" => "/300-aac-blackout-ammo.html",
|
|
"65-creedmoor" => "/6-5-creedmoor-ammo.html",
|
|
"22-lr" => "/22-lr-ammo.html",
|
|
"22-wmr" => "/22-wmr-ammo.html",
|
|
"17-hmr" => "/17-hmr-ammo.html",
|
|
"12-gauge" => "/12-gauge-ammo.html",
|
|
"16-gauge" => "/16-gauge-ammo.html",
|
|
"20-gauge" => "/20-gauge-ammo.html"
|
|
}
|
|
|
|
@impl true
|
|
def retailer_slug, do: "palmetto-state-armory"
|
|
|
|
@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("li.product-item")
|
|
|> Enum.map(&parse_item/1)
|
|
|> Enum.reject(&is_nil/1)
|
|
end
|
|
|
|
defp parse_item(item) do
|
|
with {:ok, title, url} <- extract_link_info(item),
|
|
{:ok, price_cents} <- extract_price(item),
|
|
{:ok, ppr_cents} <- extract_unit_price(item) do
|
|
%{
|
|
title: title,
|
|
url: url,
|
|
price_cents: price_cents,
|
|
price_per_round_cents: ppr_cents,
|
|
brand: extract_brand(title),
|
|
grain_weight: extract_grain_weight(title),
|
|
round_count: extract_round_count(title),
|
|
casing: TextDetector.detect_casing(title),
|
|
subsonic: TextDetector.detect_subsonic(title),
|
|
in_stock: true
|
|
}
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_link_info(item) do
|
|
case Floki.find(item, ".product-item-link") do
|
|
[{_, attrs, _} = node] ->
|
|
title = node |> Floki.text() |> String.trim()
|
|
|
|
case List.keyfind(attrs, "href", 0) do
|
|
{"href", href} -> {:ok, title, href}
|
|
_ -> :error
|
|
end
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
defp extract_price(item) do
|
|
case Floki.find(item, "[data-price-type=\"finalPrice\"]") do
|
|
[{_, attrs, _} | _] ->
|
|
case List.keyfind(attrs, "data-price-amount", 0) do
|
|
{"data-price-amount", amount} ->
|
|
{:ok, amount |> String.to_float() |> Kernel.*(100) |> round()}
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
defp extract_unit_price(item) do
|
|
case Floki.find(item, "[data-price-type=\"unit_price\"]") do
|
|
[{_, attrs, _} | _] ->
|
|
case List.keyfind(attrs, "data-price-amount", 0) do
|
|
{"data-price-amount", amount} ->
|
|
{:ok, amount |> String.to_float() |> Kernel.*(100) |> round()}
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
|
|
_ ->
|
|
:error
|
|
end
|
|
end
|
|
|
|
defp extract_brand(title) do
|
|
case String.split(title, " ", parts: 2) do
|
|
[brand | _] -> brand
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_grain_weight(title) do
|
|
case Regex.run(~r/(\d+)\s*(?:gr(?:ain)?)/i, title) do
|
|
[_, weight] -> String.to_integer(weight)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp extract_round_count(title) do
|
|
cond do
|
|
match = Regex.run(~r/(\d[\d,]*)\s*(?:rds?|rounds?)/i, title) ->
|
|
match |> Enum.at(1) |> String.replace(",", "") |> String.to_integer()
|
|
|
|
match = Regex.run(~r/(\d+)\s*rd\/box/i, title) ->
|
|
match |> Enum.at(1) |> String.to_integer()
|
|
|
|
true ->
|
|
nil
|
|
end
|
|
end
|
|
end
|