Skip to main content

game_manager_lib/scrapers/
ubisoft_plus.rs

1//! Scraper - Ubisoft+
2//!
3//! - URL: https://store.ubisoft.com/ofertas/ubisoftplus/games?access=ubisoft&offer=premium
4//! - Descrição: Scraper para obter a lista de jogos disponíveis no Ubisoft+ Premium para PC,
5//!   incluindo título, edição, gênero, imagem e link da loja.
6//! - Método: Requisição POST direta à API pública do Algolia utilizada pelo site da Ubisoft,
7//!   com chave de busca read-only exposta no frontend (padrão Algolia, sem autenticação de sessão).
8
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11
12// === Configuração do Algolia ===
13
14const ALGOLIA_BASE_URL: &str = "https://xely3u4lod-dsn.algolia.net/1/indexes/\
15     production__br_ubisoft__products__pt_BR__release_date/query";
16
17const ALGOLIA_AGENT: &str = "Algolia for JavaScript (4.13.1); Browser (lite)";
18const ALGOLIA_API_KEY: &str = "5638539fd9edb8f2c6b024b49ec375bd";
19const ALGOLIA_APP_ID: &str = "XELY3U4LOD";
20
21/// ID da oferta Ubisoft+ Premium (filtra apenas jogos do plano Premium).
22const UBISOFT_PLUS_PREMIUM_OFFER_ID: &str = "5f44de7b5cdf9a0c2027ca78";
23
24// === Tipos de dados ===
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct UbisoftGame {
29    pub title: String,
30    pub short_title: Option<String>,
31    pub edition: Option<String>,
32    pub genre: Option<String>,
33    pub image_url: Option<String>,
34    pub store_url: String,
35    pub release_date: Option<String>,
36    /// Data em que o jogo sai do catálogo Ubisoft+, se informada.
37    pub subscription_expiration_date: Option<String>,
38    /// `true` quando o jogo é classificado como adulto (+18).
39    pub adult: bool,
40    /// Plataformas além de PC disponíveis via streaming (ex.: "luna", "xbox").
41    pub streaming_platforms: Vec<String>,
42}
43
44// === Tipos internos para deserialização da resposta do Algolia ===
45
46#[derive(Debug, Deserialize)]
47struct AlgoliaResponse {
48    hits: Vec<AlgoliaHit>,
49}
50
51#[derive(Debug, Deserialize)]
52struct AlgoliaHit {
53    title: Option<String>,
54    // A API retorna snake_case; sem rename_all, os campos Rust batem direto.
55    short_title: Option<String>,
56    #[serde(rename = "Edition")]
57    edition: Option<String>,
58    #[serde(rename = "Genre")]
59    genre: Option<String>,
60    // BUG CORRIGIDO: rename_all = "camelCase" convertia este campo para "imageLink",
61    // que não existe na resposta — por isso a imagem nunca chegava.
62    image_link: Option<String>,
63    id: Option<String>,
64    release_date: Option<String>,
65    /// String vazia quando não há expiração definida.
66    #[serde(rename = "subscriptionExpirationDate")]
67    subscription_expiration_date: Option<String>,
68    /// "TRUE" | "FALSE" como string (padrão da API Algolia da Ubisoft).
69    adult: Option<String>,
70    #[serde(default, rename = "anywherePlatforms")]
71    anywhere_platforms: Vec<String>,
72}
73
74// === Função principal ===
75
76/// Obtém todos os jogos disponíveis no Ubisoft+ Premium para PC.
77///
78/// Realiza uma única requisição POST à API Algolia da Ubisoft.
79/// Não requer autenticação de sessão; a chave de API é pública (somente leitura).
80pub async fn fetch_ubisoft_plus_catalog() -> Result<Vec<UbisoftGame>, String> {
81    let client = Client::builder()
82        .user_agent(
83            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
84                     (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
85        )
86        .build()
87        .map_err(|e| e.to_string())?;
88
89    // Payload idêntico ao enviado pelo site da Ubisoft (capturado via DevTools).
90    let payload = serde_json::json!({
91        "query": "",
92        "attributesToRetrieve": [
93            "title", "image_link", "short_title", "id", "MasterID",
94            "Genre", "release_date", "partOfUbisoftPlus", "anywherePlatforms",
95            "subscriptionExpirationDate", "Edition", "adult", "partofSubscriptionOffer"
96        ],
97        "hitsPerPage": 9999,
98        "facetFilters": [
99            ["partOfUbisoftPlus:true"],
100            [],
101            [],
102            [format!("partofSubscriptionOfferID:{UBISOFT_PLUS_PREMIUM_OFFER_ID}")]
103        ],
104        "clickAnalytics": true
105    });
106
107    let url = format!(
108        "{}?x-algolia-agent={}&x-algolia-api-key={}&x-algolia-application-id={}",
109        ALGOLIA_BASE_URL,
110        urlencoding::encode(ALGOLIA_AGENT),
111        ALGOLIA_API_KEY,
112        ALGOLIA_APP_ID,
113    );
114
115    let response: AlgoliaResponse = client
116        .post(&url)
117        .header("Content-Type", "application/x-www-form-urlencoded")
118        .header("Origin", "https://store.ubisoft.com")
119        .header("Referer", "https://store.ubisoft.com/")
120        .json(&payload)
121        .send()
122        .await
123        .map_err(|e| format!("Erro na requisição: {e}"))?
124        .json()
125        .await
126        .map_err(|e| format!("Erro ao parsear resposta: {e}"))?;
127
128    let games = response
129        .hits
130        .into_iter()
131        .filter_map(|hit| {
132            // `title` e `id` são obrigatórios para construir a entrada.
133            let title = hit.title?;
134            let id = hit.id?;
135
136            // URL da loja construída a partir do ID do produto.
137            let store_url = format!("https://store.ubisoft.com/br/game/{id}.html");
138
139            Some(UbisoftGame {
140                title,
141                short_title: hit.short_title,
142                edition: hit.edition,
143                genre: hit.genre,
144                image_url: hit.image_link,
145                store_url,
146                release_date: hit.release_date,
147                subscription_expiration_date: hit
148                    .subscription_expiration_date
149                    .filter(|s| !s.is_empty()),
150                adult: hit.adult.as_deref() == Some("TRUE"),
151                streaming_platforms: hit.anywhere_platforms,
152            })
153        })
154        .collect();
155
156    Ok(games)
157}