- 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)
96 lines
2.8 KiB
Elixir
96 lines
2.8 KiB
Elixir
defmodule Ammoprices.Scraping.Retailers.TrueShotAmmo do
|
|
@moduledoc false
|
|
@behaviour Ammoprices.Scraping.Scraper
|
|
|
|
alias Ammoprices.Scraping.TextDetector
|
|
|
|
@slug_to_collection %{
|
|
"9mm-luger" => "ammunition-pistol-ammo-9mm",
|
|
"45-acp" => "ammunition-pistol-ammo-45-acp",
|
|
"380-acp" => "ammunition-pistol-ammo-380-acp",
|
|
"40-sw" => "ammunition-pistol-ammo-40-s-w",
|
|
"38-special" => "ammunition-pistol-ammo-38-special",
|
|
"357-magnum" => "ammunition-pistol-ammo-357-magnum",
|
|
"10mm-auto" => "ammunition-pistol-ammo-10mm",
|
|
"223-rem" => "ammunition-rifle-ammo-223-rem",
|
|
"556-nato" => "ammunition-rifle-ammo-5-56x45mm",
|
|
"308-win" => "ammunition-rifle-ammo-308-7-62x51",
|
|
"762x39" => "ammunition-rifle-ammo-7-62x39",
|
|
"300-blackout" => "ammunition-rifle-ammo-300-blackout",
|
|
"65-creedmoor" => "ammunition-rifle-ammo-6-5-creedmoor",
|
|
"22-lr" => "ammunition-rimfire-ammo-22-lr",
|
|
"12-gauge" => "ammunition-shotgun-ammo-12-gauge",
|
|
"20-gauge" => "ammunition-shotgun-ammo-20-gauge"
|
|
}
|
|
|
|
@impl true
|
|
def retailer_slug, do: "true-shot-ammo"
|
|
|
|
@impl true
|
|
def category_url(caliber) do
|
|
case Map.get(@slug_to_collection, caliber.slug) do
|
|
nil -> nil
|
|
collection -> "/collections/#{collection}/products.json"
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def parse_products(%{"products" => products}) do
|
|
products
|
|
|> Enum.map(&parse_product/1)
|
|
|> Enum.reject(&is_nil/1)
|
|
end
|
|
|
|
def parse_products(_), do: []
|
|
|
|
defp parse_product(product) do
|
|
title = product["title"]
|
|
first_variant = List.first(product["variants"])
|
|
|
|
with variant when not is_nil(variant) <- first_variant,
|
|
price_cents when is_integer(price_cents) <- parse_price(variant["price"]) do
|
|
round_count = parse_round_count(variant["title"])
|
|
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
|
|
|
|
%{
|
|
title: title,
|
|
url: "/products/#{product["handle"]}",
|
|
brand: product["vendor"],
|
|
price_cents: price_cents,
|
|
price_per_round_cents: ppr,
|
|
grain_weight: extract_grain_weight(title),
|
|
round_count: round_count,
|
|
casing: TextDetector.detect_casing(title),
|
|
subsonic: TextDetector.detect_subsonic(title),
|
|
in_stock: variant["available"] == true
|
|
}
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_price(price_str) when is_binary(price_str) do
|
|
price_str
|
|
|> String.to_float()
|
|
|> Kernel.*(100)
|
|
|> round()
|
|
end
|
|
|
|
defp parse_price(_), do: nil
|
|
|
|
defp parse_round_count(title) when is_binary(title) do
|
|
case Integer.parse(title) do
|
|
{count, _} -> count
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_round_count(_), do: nil
|
|
|
|
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
|
|
end
|