ammocpr/lib/ammoprices/scraping/runner.ex
Graham McIntire 2efb35dc48
Stop scrape chain from dying on a single bad product
A scraped product with nil price_per_round_cents made Prices.create_snapshot
return {:error, changeset}, which blew up the {:ok, _} match in the runner,
crashed the whole perform, and after 3 retries discarded the job. Nothing
reseeds the chain once the job is discarded, so prod scrapes silently died
on Mar 17 and stayed dead until the app was restarted.

- Runner handles snapshot insert errors by logging and continuing instead
  of raising.
- ScrapeJob wraps each Runner.run in try/rescue so one retailer crashing
  doesn't take down the whole cycle.
- ScrapeJob moves schedule_next into an after block so the chain is always
  reseeded, even if the scrape loop crashes.
2026-04-22 15:51:15 -05:00

103 lines
2.8 KiB
Elixir

defmodule Ammoprices.Scraping.Runner do
@moduledoc false
alias Ammoprices.Catalog
alias Ammoprices.Prices
alias Ammoprices.Scraping.HttpClient
require Logger
def run(scraper, caliber) do
retailer = Catalog.get_retailer_by_slug!(scraper.retailer_slug())
if function_exported?(scraper, :fetch, 2) do
do_fetch(scraper, retailer, caliber)
else
case scraper.category_url(caliber) do
nil ->
{:ok, %{products_count: 0, snapshots_count: 0}}
path ->
url = retailer.base_url <> path
do_scrape(scraper, retailer, caliber, url)
end
end
end
defp do_fetch(scraper, retailer, caliber) do
case scraper.fetch(retailer, caliber) do
{:ok, parsed} ->
process_results(parsed, retailer, caliber)
{:error, reason} ->
{:error, reason}
end
end
defp do_scrape(scraper, retailer, caliber, url) do
case HttpClient.get(url) do
{:ok, %{status: 200, body: body}} ->
parsed = scraper.parse_products(body)
process_results(parsed, retailer, caliber)
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, reason} ->
{:error, reason}
end
end
defp process_results(parsed, retailer, caliber) do
now = DateTime.truncate(DateTime.utc_now(), :second)
results = Enum.map(parsed, &process_product(&1, retailer, caliber, now))
successful = Enum.count(results, &(&1 == :ok))
if successful > 0 do
Catalog.mark_unseen_products_out_of_stock(retailer.id, caliber.id, now)
end
Catalog.update_retailer(retailer, %{last_scraped_at: now})
{:ok, %{products_count: successful, snapshots_count: successful}}
end
defp process_product(product_data, retailer, caliber, now) do
product_attrs = %{
title: product_data.title,
url: product_data.url,
brand: product_data.brand,
grain_weight: product_data.grain_weight,
round_count: product_data.round_count,
casing: product_data.casing,
condition: "new",
in_stock: product_data.in_stock,
subsonic: Map.get(product_data, :subsonic, false),
last_seen_at: now
}
with {:ok, product} <- Catalog.upsert_product(retailer.id, caliber.id, product_attrs),
{:ok, _snapshot} <- create_snapshot(product, product_data, now) do
:ok
else
{:error, %Ecto.Changeset{} = changeset} ->
Logger.warning(
"Scrape insert failed for #{retailer.slug} #{product_data.url}: " <>
inspect(changeset.errors)
)
:error
end
end
defp create_snapshot(product, product_data, now) do
Prices.create_snapshot(product.id, %{
price_cents: product_data.price_cents,
price_per_round_cents: product_data.price_per_round_cents,
in_stock: product_data.in_stock,
recorded_at: now
})
end
end