Previously combined as a single "556-223" caliber. Now tracked separately with dedicated scraper URLs per retailer. Also fixes broken URLs for Lucky Gunner, True Shot Ammo, and Palmetto State Armory.
193 lines
4.9 KiB
Elixir
193 lines
4.9 KiB
Elixir
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",
|
|
"223-rem" => "/223-remington-ammo-c-83.aspx",
|
|
"556-nato" => "/556mm-nato-ammo-c-2719.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",
|
|
"16-gauge" => "/16-gauge-shotgun-ammo-c-748.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
|