ammocpr/lib/ammoprices/scraping/retailers/lucky_gunner.ex

196 lines
5.1 KiB
Elixir

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",
"380-acp" => "/handgun/380-ammo",
"40-sw" => "/handgun/40-sw-ammo",
"38-special" => "/handgun/38-special-ammo",
"357-magnum" => "/handgun/357-mag-ammo",
"10mm-auto" => "/handgun/10mm-ammo",
"223-rem" => "/rifle/223-remington-ammo",
"556-nato" => "/rifle/5.56x45-ammo",
"308-win" => "/rifle/308-ammo",
"762x39" => "/rifle/7.62x39-ammo",
"30-06" => "/rifle/30-06-ammo",
"300-blackout" => "/rifle/300-blackout-ammo",
"65-creedmoor" => "/rifle/6.5-creedmoor-ammo",
"22-lr" => "/rimfire/22-lr-ammo",
"22-wmr" => "/rimfire/22-wmr-ammo",
"17-hmr" => "/rimfire/17-hmr-ammo",
"12-gauge" => "/shotgun/12-gauge-ammo",
"16-gauge" => "/shotgun/16-gauge-ammo",
"20-gauge" => "/shotgun/20-gauge-ammo"
}
@impl true
def retailer_slug, do: "lucky-gunner"
@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(".products-list .item")
|> 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
desc_items = extract_description_items(item)
%{
title: title,
url: url,
price_cents: price_cents,
price_per_round_cents: ppr_cents,
brand: extract_brand(desc_items),
grain_weight: extract_grain_weight(desc_items),
round_count: extract_round_count(title),
casing: extract_casing(desc_items) || 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, ".product-name a span") do
[{_, _, children}] -> {:ok, children |> Floki.text() |> String.trim()}
_ -> :error
end
end
defp extract_url(item) do
case Floki.find(item, ".product-name 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, ".regular-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
with [{_, _, _} = node] <- Floki.find(item, ".cprc"),
text = node |> Floki.text() |> String.trim(),
[_, amount] <- Regex.run(~r/([0-9.]+)¢/, text) do
{:ok, parse_cents(amount)}
else
_ -> :error
end
end
defp parse_cents(amount) do
if String.contains?(amount, ".") do
amount |> String.to_float() |> trunc()
else
String.to_integer(amount)
end
end
defp extract_description_items(item) do
item
|> Floki.find(".desc li")
|> Enum.map(fn node -> node |> Floki.text() |> String.trim() end)
end
defp extract_brand(desc_items) do
Enum.find_value(desc_items, fn item ->
case Regex.run(~r/Manufacturer\s*-\s*(.+)/i, item) do
[_, brand] -> String.trim(brand)
_ -> nil
end
end)
end
defp extract_grain_weight(desc_items) do
Enum.find_value(desc_items, fn item ->
case Regex.run(~r/(\d+)\s*grain/i, item) do
[_, weight] -> String.to_integer(weight)
_ -> nil
end
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_casing(desc_items) do
casing_text =
Enum.find_value(desc_items, fn item ->
case Regex.run(~r/[Cc]asings?\s*-\s*(.+)/i, item) do
[_, text] -> String.downcase(String.trim(text))
_ -> nil
end
end)
case casing_text do
nil -> nil
text when is_binary(text) -> detect_casing_material(text)
end
end
defp detect_casing_material(text) do
cond do
String.contains?(text, "brass") -> "brass"
String.contains?(text, "steel") -> "steel"
String.contains?(text, "aluminum") -> "aluminum"
String.contains?(text, "nickel") -> "brass"
true -> nil
end
end
defp extract_in_stock(item) do
case Floki.find(item, ".in-stock") do
[_ | _] -> true
_ -> false
end
end
end