use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use grabcli_core::Context;
use grabcli_core::cache::CacheKey;
use grabcli_core::error::{Error, ErrorKind, Result};
use grabcli_core::http::{Hydra, Request};
use grabcli_core::jwt;
use grabcli_core::location::Location;
use serde_json::Value;
use crate::{api, recaptcha};
pub const CACHE_NS: &str = "mart";
pub const MIN_GAP: Duration = Duration::from_millis(2000);
const RETRY_AFTER_429: Duration = Duration::from_secs(10);
pub struct Mart {
pub ctx: Context,
pub loc: Location,
cdp: String,
pacer: Arc<dyn Pacer>,
pub notices: bool,
}
#[async_trait]
pub trait Pacer: Send + Sync {
async fn wait(&self);
}
#[derive(Debug, Default)]
pub struct MemoryPacer {
last: Mutex<Option<Instant>>,
}
#[async_trait]
impl Pacer for MemoryPacer {
async fn wait(&self) {
let wait = {
let last = self
.last
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
last.map(|t| MIN_GAP.saturating_sub(t.elapsed()))
};
if let Some(w) = wait.filter(|w| !w.is_zero()) {
tokio::time::sleep(w).await;
}
*self
.last
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Instant::now());
}
}
fn mart_headers() -> Vec<(String, String)> {
vec![
("origin".into(), api::ORIGIN.into()),
("referer".into(), format!("{}/", api::ORIGIN)),
("x-country-code".into(), "PH".into()),
]
}
pub fn map_err(e: Error) -> Error {
match e.kind {
ErrorKind::RateLimited => Error::new(
ErrorKind::RateLimited,
"GrabMart rate limit (429): the mart API allows only ~10 quick calls. \
Wait a minute and retry; cached results are served without calls.",
),
ErrorKind::Api if e.message.contains("Recaptcha") => Error::new(
ErrorKind::Api,
"GrabMart rejected the reCAPTCHA token (403 Recaptcha failed). Reload \
mart.grab.com in the CDP browser and retry.",
),
_ => e,
}
}
fn mart_auth_err(e: Error) -> Error {
if e.kind != ErrorKind::AuthExpired {
return e;
}
Error::new(
ErrorKind::AuthExpired,
format!(
"GrabMart quotes need a logged-in Grab session and the silent GrabMart login \
failed ({}). Run `grab auth login` (browsing still works without it).",
e.message
),
)
}
impl Mart {
pub fn new(ctx: Context) -> Result<Self> {
let allow = api::allowlist(ctx.config.allow_ordering);
let ctx = ctx.with_allowlist(&allow)?;
let loc = Location::from_session(ctx.session())?;
let cdp = ctx.cdp_base();
Ok(Self {
ctx,
loc,
cdp,
pacer: Arc::new(MemoryPacer::default()),
notices: false,
})
}
#[must_use]
pub fn with_pacer(mut self, pacer: Arc<dyn Pacer>) -> Self {
self.pacer = pacer;
self
}
pub fn key(&self, kind: grabcli_core::cache::CacheKind, endpoint: &str) -> CacheKey {
CacheKey::new(CACHE_NS, kind, endpoint).location(self.loc.latitude, self.loc.longitude)
}
pub async fn public(&mut self, key: &CacheKey, url: &str, action: &str) -> Result<Value> {
let cache = self.ctx.cache().clone();
cache
.get_or_fetch(key, || self.fetch_public(url, action))
.await
}
pub async fn fetch_public(&mut self, url: &str, action: &str) -> Result<Value> {
let mut attempt = 0;
loop {
self.pacer.wait().await;
let token = recaptcha::mint(&self.cdp, action).await?;
let mut req = Request::get(url, Hydra::NotNeeded);
req.extra_headers = mart_headers();
req.extra_headers.push(("x-recaptcha-token".into(), token));
match self.ctx.call(&req).await {
Err(e) if e.kind == ErrorKind::RateLimited && attempt == 0 => {
attempt += 1;
if self.notices {
eprintln!(
"GrabMart rate limit (429); retrying once in {}s…",
RETRY_AFTER_429.as_secs()
);
}
tokio::time::sleep(RETRY_AFTER_429).await;
}
other => return other.map_err(map_err),
}
}
}
pub async fn ensure_mart_session(&mut self) -> Result<bool> {
if self.ctx.has_mart_session() {
return Ok(false);
}
self.ctx.login_mart().await.map_err(mart_auth_err)?;
Ok(true)
}
pub async fn quote_call(&mut self, body: Value) -> Result<Value> {
let mut mart_login_done = self.ensure_mart_session().await?;
loop {
self.pacer.wait().await;
let mut req = Request::post(api::CART, body.clone(), Hydra::Required).no_refresh();
req.extra_headers = mart_headers();
match self.ctx.call(&req).await {
Err(e) if e.kind == ErrorKind::AuthExpired && !mart_login_done => {
mart_login_done = true;
self.ctx.login_mart().await.map_err(mart_auth_err)?;
}
Err(e) if e.kind == ErrorKind::AuthExpired => return Err(mart_auth_err(e)),
other => return other.map_err(map_err),
}
}
}
pub fn payment_default(&self) -> String {
self.ctx
.config
.payment_type_id
.clone()
.or_else(|| self.ctx.session().payment_type_id.clone())
.unwrap_or_default()
}
pub fn logged_in(&self) -> bool {
self.ctx
.session()
.gfc_session_exp()
.is_some_and(|e| e > jwt::now())
}
}