Files
consume-rs/backend/src/fetch/shopify.rs
T
2026-06-17 10:59:45 +02:00

186 lines
6.2 KiB
Rust

//! 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<String>,
#[serde(default)]
featured_image: Option<String>,
#[serde(default)]
variants: Vec<JsVariant>,
}
#[derive(Deserialize)]
struct JsVariant {
/// Price in the currency's minor units (cents), e.g. 19795 = 197.95.
price: i64,
#[serde(default)]
available: Option<bool>,
}
/// 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<Option<FetchedProduct>> {
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<Option<JsDoc>> {
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::<JsDoc>(&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<i64> {
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<bool> {
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<String> {
#[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<String> {
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<String> {
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}"))
}