Skip to main content

game_manager_lib/scrapers/
game_pass.rs

1//! Scraper - Game Pass PC
2
3use crate::constants::GAME_PASS_BATCH_SIZE;
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6
7// IDs dos catálogos
8const GAME_PASS_PC_SIGL: &str = "fdd9e2a7-0fee-49f6-ad69-4354098401ff";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct GamePassGame {
13    pub store_id: String,
14    pub title: String,
15    pub description: Option<String>,
16    pub image_poster: Option<String>, // capa 2:3 para cards
17    pub image_hero: Option<String>,   // banner 16:9 para carrossel
18    pub categories: Vec<String>,
19    pub developer: Option<String>,
20    pub is_ea_play: bool,
21    pub store_url: String,
22}
23
24fn parse_game_pass_products(
25    products: &serde_json::Value,
26    exclude_ea_play: bool,
27) -> Vec<GamePassGame> {
28    let mut games = Vec::new();
29
30    let Some(products) = products.as_object() else {
31        return games;
32    };
33
34    for (store_id, product) in products {
35        // Filtra não-jogos
36        if product["ProductType"].as_str() != Some("Game") {
37            continue;
38        }
39
40        // Filtra EA Play se solicitado
41        let is_ea_play = product["IsEAPlay"].as_bool().unwrap_or(false);
42        if exclude_ea_play && is_ea_play {
43            continue;
44        }
45
46        // Confirma que roda no PC
47        let platforms = product["AllowedPlatforms"]
48            .as_array()
49            .map(|p| p.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
50            .unwrap_or_default();
51
52        if !platforms.contains(&"Windows.Desktop") {
53            continue;
54        }
55
56        let categories = product["Categories"]
57            .as_array()
58            .map(|arr| {
59                arr.iter()
60                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
61                    .collect()
62            })
63            .unwrap_or_default();
64
65        games.push(GamePassGame {
66            store_id: store_id.clone(),
67            title: product["ProductTitle"].as_str().unwrap_or("?").to_string(),
68            description: product["ProductDescription"]
69                .as_str()
70                .map(|s| s.to_string()),
71            image_poster: product["ImagePoster"]["URI"]
72                .as_str()
73                .map(|s| s.to_string()),
74            image_hero: product["ImageHero"]["URI"].as_str().map(|s| s.to_string()),
75            categories,
76            developer: product["DeveloperName"].as_str().map(|s| s.to_string()),
77            is_ea_play,
78            store_url: format!("https://www.xbox.com/pt-BR/games/store/_/{}", store_id),
79        });
80    }
81
82    games
83}
84
85pub async fn fetch_game_pass_pc_catalog(
86    exclude_ea_play: bool,
87    language: &str,
88) -> Result<Vec<GamePassGame>, String> {
89    let client = Client::new();
90
91    // Etapa 1 — lista de IDs
92    let url = format!(
93        "https://catalog.gamepass.com/sigls/v2?id={}&language={}&market=BR",
94        GAME_PASS_PC_SIGL, language
95    );
96
97    let sigls: Vec<serde_json::Value> = client
98        .get(&url)
99        .send()
100        .await
101        .map_err(|e| e.to_string())?
102        .json()
103        .await
104        .map_err(|e| e.to_string())?;
105
106    // Extrai apenas os IDs (pula o primeiro objeto que é metadata do catálogo)
107    let ids: Vec<String> = sigls
108        .iter()
109        .filter_map(|v| v["id"].as_str().map(|s| s.to_string()))
110        .collect();
111
112    // Etapa 2 — detalhes em batches de 20
113    let mut games = Vec::new();
114
115    for chunk in ids.chunks(GAME_PASS_BATCH_SIZE) {
116        let body = serde_json::json!({ "Products": chunk });
117
118        let response: serde_json::Value = client
119            .post(format!(
120                "https://catalog.gamepass.com/products?market=BR&language={}&hydration=MobileDetailsForConsole",
121                language
122            ))
123            .header("Content-Type", "application/json")
124            .json(&body)
125            .send()
126            .await
127            .map_err(|e| e.to_string())?
128            .json()
129            .await
130            .map_err(|e| e.to_string())?;
131
132        games.extend(parse_game_pass_products(
133            &response["Products"],
134            exclude_ea_play,
135        ));
136
137        // Delay entre batches para não sobrecarregar
138        if !chunk.is_empty() {
139            tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
140        }
141    }
142
143    Ok(games)
144}