- 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)
148 lines
3.6 KiB
Elixir
148 lines
3.6 KiB
Elixir
defmodule Ammoprices.Scraping.Retailers.Natchez do
|
|
@moduledoc false
|
|
@behaviour Ammoprices.Scraping.Scraper
|
|
|
|
alias Ammoprices.Scraping.HttpClient
|
|
alias Ammoprices.Scraping.TextDetector
|
|
|
|
@slug_to_caliber %{
|
|
"9mm-luger" => "9mm Luger",
|
|
"45-acp" => "45 Auto",
|
|
"380-acp" => "380 Auto",
|
|
"40-sw" => "40 S&W",
|
|
"38-special" => "38 Spl",
|
|
"357-magnum" => "357 Mag",
|
|
"10mm-auto" => "10mm Auto",
|
|
"223-rem" => "223 Rem",
|
|
"556-nato" => "5.56mm",
|
|
"308-win" => "308 Win",
|
|
"762x39" => "7.62x39mm",
|
|
"30-06" => "30-06 Sprg",
|
|
"300-blackout" => "300 Blk",
|
|
"65-creedmoor" => "6.5 Creedmoor",
|
|
"22-lr" => "22 LR",
|
|
"22-wmr" => "22 WMR",
|
|
"17-hmr" => "17 HMR",
|
|
"12-gauge" => "12 Gauge",
|
|
"16-gauge" => "16 Gauge",
|
|
"20-gauge" => "20 Gauge"
|
|
}
|
|
|
|
@impl true
|
|
def retailer_slug, do: "natchez"
|
|
|
|
@impl true
|
|
def category_url(_caliber), do: nil
|
|
|
|
@impl true
|
|
def parse_products(_), do: []
|
|
|
|
@impl true
|
|
def fetch(retailer, caliber) do
|
|
case build_query(caliber.slug) do
|
|
nil ->
|
|
{:ok, []}
|
|
|
|
query ->
|
|
url = retailer.base_url <> "/graphql"
|
|
|
|
case HttpClient.post_json(url, %{query: query}, [{"store", "default"}]) do
|
|
{:ok, %{status: 200, body: body}} -> {:ok, parse_graphql_response(body)}
|
|
{:ok, %{status: s}} -> {:error, "HTTP #{s}"}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
def build_query(slug) do
|
|
case Map.get(@slug_to_caliber, slug) do
|
|
nil ->
|
|
nil
|
|
|
|
caliber_value ->
|
|
escaped = escape_gql_string(caliber_value)
|
|
|
|
"""
|
|
{
|
|
products(
|
|
search: "#{escaped}"
|
|
filter: { caliber: { eq: "#{escaped}" } }
|
|
pageSize: 48
|
|
currentPage: 1
|
|
) {
|
|
items {
|
|
name sku url_key stock_status brand grain rounds case_material
|
|
price_range { minimum_price { final_price { value } } }
|
|
}
|
|
total_count
|
|
page_info { total_pages }
|
|
}
|
|
}
|
|
"""
|
|
end
|
|
end
|
|
|
|
defp escape_gql_string(str) do
|
|
str
|
|
|> String.replace("\\", "\\\\")
|
|
|> String.replace("\"", "\\\"")
|
|
|> String.replace("\n", "\\n")
|
|
end
|
|
|
|
def parse_graphql_response(%{"data" => %{"products" => %{"items" => items}}}) do
|
|
items
|
|
|> Enum.map(&parse_item/1)
|
|
|> Enum.reject(&is_nil/1)
|
|
end
|
|
|
|
def parse_graphql_response(_), do: []
|
|
|
|
defp parse_item(item) do
|
|
case extract_price(item) do
|
|
{:ok, price_cents} ->
|
|
round_count = parse_integer(item["rounds"])
|
|
ppr = if round_count && round_count > 0, do: round(price_cents / round_count)
|
|
name = item["name"]
|
|
|
|
%{
|
|
title: name,
|
|
url: "/#{item["url_key"]}.html",
|
|
brand: item["brand"],
|
|
price_cents: price_cents,
|
|
price_per_round_cents: ppr,
|
|
grain_weight: parse_integer(item["grain"]),
|
|
round_count: round_count,
|
|
casing: parse_casing(item["case_material"]),
|
|
subsonic: TextDetector.detect_subsonic(name),
|
|
in_stock: item["stock_status"] == "IN_STOCK"
|
|
}
|
|
|
|
:error ->
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp extract_price(%{"price_range" => %{"minimum_price" => %{"final_price" => %{"value" => value}}}}) do
|
|
{:ok,
|
|
value
|
|
|> String.to_float()
|
|
|> Kernel.*(100)
|
|
|> round()}
|
|
end
|
|
|
|
defp extract_price(_), do: :error
|
|
|
|
defp parse_integer(nil), do: nil
|
|
|
|
defp parse_integer(str) when is_binary(str) do
|
|
case Integer.parse(str) do
|
|
{n, _} -> n
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_integer(_), do: nil
|
|
|
|
defp parse_casing(nil), do: nil
|
|
defp parse_casing(material), do: String.downcase(material)
|
|
end
|