//! Generic Shopify storefront adapter. //! //! Every Shopify shop exposes an Ajax product document at //! `https://{shop}/products/{handle}.js`, which carries title, image, per-variant //! price (integer minor units) and availability. We derive that URL from the //! pasted product URL (any path with a `/products/{handle}` segment). No shop is //! hardcoded. //! //! Pricing is the subtle part. The `.js` doc reports the shop's *base* currency //! price. Shops using Shopify Markets show visitors a converted *presentment* //! price (e.g. a PLN shop shows EUR in the EU). That conversion is reachable //! generically via `.js?currency=EUR`. So we fetch both and: //! - if the converted price differs from the base price → Markets converted it, //! and the price is genuinely in our requested currency; //! - if they're equal → no conversion happened, so the price is in the shop's //! base currency (read from `/meta.json`), not our requested one. //! This stops us from mislabelling e.g. "821 EUR" when the value is 821 PLN. use rust_decimal::Decimal; use serde::Deserialize; use url::{Position, Url}; use super::FetchedProduct; #[derive(Deserialize)] struct JsDoc { #[serde(default)] title: Option, #[serde(default)] featured_image: Option, #[serde(default)] variants: Vec, } #[derive(Deserialize)] struct JsVariant { /// Price in the currency's minor units (cents), e.g. 19795 = 197.95. price: i64, #[serde(default)] available: Option, } /// Returns `Ok(None)` when the URL isn't a Shopify product URL (so other /// adapters could try), `Err` when it looks like one but the fetch/parse fails. pub async fn fetch( client: &reqwest::Client, raw_url: &str, default_currency: &str, ) -> anyhow::Result> { let Some(base_url) = product_doc_url(raw_url, "js") else { return Ok(None); }; // Presentment price in the requested currency (Markets-converted if enabled). let conv_url = format!("{base_url}?currency={default_currency}"); let Some(conv) = fetch_js(client, &conv_url).await? else { return Ok(None); }; let Some(conv_cents) = cheapest(&conv.variants) else { anyhow::bail!("Shopify product has no readable price"); }; // Base price (no currency param) to detect whether conversion happened. let base = fetch_js(client, &base_url).await?; let base_cents = base.as_ref().and_then(|b| cheapest(&b.variants)); let (cents, currency) = match base_cents { // Converted: value really is in the requested currency. Some(b) if b != conv_cents => (conv_cents, default_currency.to_string()), // No conversion: value is the shop's base currency. Some(b) => ( b, shop_currency(client, raw_url) .await .unwrap_or_else(|| default_currency.to_string()), ), None => (conv_cents, default_currency.to_string()), }; let in_stock = availability(&conv.variants); let title = conv .title .clone() .or_else(|| base.as_ref().and_then(|b| b.title.clone())) .unwrap_or_else(|| "Untitled product".to_string()); let image_url = conv .featured_image .clone() .or_else(|| base.and_then(|b| b.featured_image)) .map(normalize_image); Ok(Some(FetchedProduct { title, price: Decimal::new(cents, 2), currency, image_url, in_stock, source: "shopify", })) } /// GET a `.js` product doc. `Ok(None)` if it isn't a Shopify product document. async fn fetch_js(client: &reqwest::Client, url: &str) -> anyhow::Result> { let resp = client.get(url).send().await?; if !resp.status().is_success() { return Ok(None); } let body = resp.text().await?; Ok(serde_json::from_str::(&body).ok()) } /// Cheapest available variant's price (minor units); falls back to cheapest /// overall. `None` if there are no priced variants. fn cheapest(variants: &[JsVariant]) -> Option { variants .iter() .filter(|v| v.available == Some(true)) .map(|v| v.price) .min() .or_else(|| variants.iter().map(|v| v.price).min()) } /// `Some(true/false)` if any variant reported availability, else `None`. fn availability(variants: &[JsVariant]) -> Option { if variants.iter().all(|v| v.available.is_none()) { return None; } Some(variants.iter().any(|v| v.available == Some(true))) } /// The shop's base ISO 4217 currency, from `{origin}/meta.json`. Best-effort. async fn shop_currency(client: &reqwest::Client, raw_url: &str) -> Option { #[derive(Deserialize)] struct Meta { currency: String, } let origin = origin_of(raw_url)?; let resp = client .get(format!("{origin}/meta.json")) .send() .await .ok()?; if !resp.status().is_success() { return None; } let meta: Meta = resp.json().await.ok()?; let c = meta.currency.trim().to_uppercase(); (c.len() == 3).then_some(c) } /// Shopify image URLs are often protocol-relative (`//cdn.shopify.com/...`). fn normalize_image(src: String) -> String { if let Some(rest) = src.strip_prefix("//") { format!("https://{rest}") } else { src } } fn origin_of(raw: &str) -> Option { let u = Url::parse(raw).ok()?; if !matches!(u.scheme(), "http" | "https") { return None; } Some(u[..Position::BeforePath].to_string()) } /// Build `{origin}/products/{handle}.{ext}`, or `None` if there's no /// `/products/{handle}` segment. fn product_doc_url(raw: &str, ext: &str) -> Option { let u = Url::parse(raw).ok()?; if !matches!(u.scheme(), "http" | "https") { return None; } let segs: Vec<&str> = u.path_segments()?.filter(|s| !s.is_empty()).collect(); let pos = segs.iter().position(|s| *s == "products")?; let handle = segs.get(pos + 1)?; let handle = handle.trim_end_matches(".json").trim_end_matches(".js"); if handle.is_empty() { return None; } let origin = &u[..Position::BeforePath]; Some(format!("{origin}/products/{handle}.{ext}")) }