186 lines
6.2 KiB
Rust
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}"))
|
|
}
|