Skip to main content

game_manager_lib/services/integration/pcgamingwiki/
scraper.rs

1//! Parser de wikitext do PCGamingWiki para dados não disponíveis via Cargo API.
2//!
3//! Extrai dois tipos de dados da wikitext bruta da página via `action=parse`:
4//!
5//! - **System requirements** — blocos `{{System requirements}}`, um por OS.
6//!   Suporta parâmetros padrão (`minCPU`, `recGPU`, etc.), campos duplos
7//!   (`minCPU2`, `minGPU2` para alternativas AMD/Intel) e tiers extras
8//!   (`alt1Title`/`alt1CPU`/etc., comuns em jogos AAA modernos como Witcher 3).
9//!
10//! - **Game data paths** — blocos `{{Game data/config|OS|path}}` e `{{Game data/saves|OS|path}}`.
11//!
12//! # Estratégia de parsing
13//!
14//! O wikitext não é parseado como árvore — isso seria frágil e lento.
15//! Em vez disso, identificamos blocos de template por nome (`{{System requirements`),
16//! extraímos o conteúdo até o `}}` de fechamento correspondente (respeitando
17//! aninhamento), e dentro do bloco extraímos parâmetros com regex simples
18//! `|chave = valor`.
19//!
20//! # Normalização de valores
21//!
22//! Todos os valores booleanos da PCGW (`true`, `false`, `unknown`, `n/a`,
23//! `hackable`, `limited`, `always on`) são preservados como `Option<String>`
24//! para que o frontend decida a semântica de exibição.
25//!
26//! # Integração com fetch.rs
27//!
28//! Este módulo é chamado em paralelo com as queries Cargo existentes.
29//! A busca do wikitext usa `action=parse&prop=wikitext` e requer uma chamada
30//! HTTP adicional — deve respeitar o mesmo rate limiting de 250ms.
31
32use crate::errors::AppError;
33use crate::models::{GameDataPath, PcgwScrapedData, SystemRequirements};
34use once_cell::sync::Lazy;
35use regex::Regex;
36
37/// Captura `|chave = valor` dentro de um bloco de template.
38/// Grupos: 1 = chave (sem espaços), 2 = valor (sem espaços nas bordas).
39/// O valor é delimitado por `[^|}\n]*` — a própria classe de caracteres já
40/// para antes de `|`, `}` ou newline, tornando o lookahead desnecessário
41/// (e incompatível com a crate `regex`, que não suporta look-around).
42static RE_PARAM: Lazy<Regex> =
43    Lazy::new(|| Regex::new(r"\|([^=|}\n]+?)\s*=\s*([^|}\n]*)").expect("RE_PARAM: regex inválida"));
44
45/// Captura apenas o início de `{{Game data/config|` ou `{{Game data/saves|`.
46/// O path completo é extraído manualmente para suportar `{{p|variavel}}` aninhado.
47static RE_GAME_DATA_START: Lazy<Regex> = Lazy::new(|| {
48    Regex::new(r"\{\{Game data/(config|saves)\|([^|]+)\|")
49        .expect("RE_GAME_DATA_START: regex inválida")
50});
51
52/// Extrai o conteúdo do 3º argumento de um template `{{Game data/...}}`,
53/// respeitando aninhamento de `{{p|...}}` internos.
54///
55/// `rest` deve começar logo após o `|` que precede o path.
56/// Retorna o path bruto e o índice após o `}}` de fechamento.
57fn extract_path_arg(rest: &str) -> Option<&str> {
58    let bytes = rest.as_bytes();
59    let mut depth = 1usize; // já estamos dentro do template externo
60    let mut pos = 0;
61    let path_start = 0;
62
63    while pos < bytes.len().saturating_sub(1) {
64        if bytes[pos] == b'{' && bytes[pos + 1] == b'{' {
65            depth += 1;
66            pos += 2;
67        } else if bytes[pos] == b'}' && bytes[pos + 1] == b'}' {
68            depth -= 1;
69            if depth == 0 {
70                // pos é onde começa o }} de fechamento do template externo
71                return Some(rest[path_start..pos].trim());
72            }
73            pos += 2;
74        } else if bytes[pos] == b'|' && depth == 1 {
75            // Pipe no nível do template externo = fim do argumento
76            return Some(rest[path_start..pos].trim());
77        } else {
78            pos += 1;
79        }
80    }
81
82    None
83}
84
85/// Busca o wikitext bruto de uma página do PCGamingWiki pelo `page_id`.
86///
87/// Usa `action=parse&prop=wikitext` — retorna o wikitext completo da página.
88/// Deve ser chamado após a query `Infobox_game` que resolve o `page_id`.
89///
90/// # Rate limiting
91/// Adiciona o delay padrão de 250ms antes da requisição.
92pub async fn fetch_wikitext(client: &reqwest::Client, page_id: &str) -> Result<String, AppError> {
93    use std::time::Duration;
94    use tokio::time::sleep;
95
96    // Mesmo delay das queries Cargo para respeitar rate limit de 30 req/min
97    sleep(Duration::from_millis(250)).await;
98
99    let response = client
100        .get("https://www.pcgamingwiki.com/w/api.php")
101        .query(&[
102            ("action", "parse"),
103            ("pageid", page_id),
104            ("prop", "wikitext"),
105            ("format", "json"),
106        ])
107        .send()
108        .await
109        .map_err(|e| AppError::NetworkError(e.to_string()))?;
110
111    if !response.status().is_success() {
112        return Err(AppError::NetworkError(format!(
113            "PCGW wikitext API retornou HTTP {}",
114            response.status()
115        )));
116    }
117
118    let json: serde_json::Value = response
119        .json()
120        .await
121        .map_err(|e| AppError::ParseError(e.to_string()))?;
122
123    let wikitext = json
124        .pointer("/parse/wikitext/*")
125        .and_then(|v| v.as_str())
126        .ok_or_else(|| AppError::ParseError("Campo wikitext ausente na resposta".to_string()))?
127        .to_string();
128
129    Ok(wikitext)
130}
131
132/// Encontra todas as ocorrências de `{{NomeDoTemplate` no wikitext e retorna
133/// o conteúdo interno de cada bloco (sem as chaves externas), respeitando
134/// aninhamento de templates.
135///
136/// Exemplo: para `{{System requirements\n|OSfamily = Windows\n}}` retorna
137/// `"System requirements\n|OSfamily = Windows\n"`.
138fn extract_template_blocks<'a>(wikitext: &'a str, template_name: &str) -> Vec<&'a str> {
139    let marker = format!("{{{{{}", template_name);
140    let mut blocks = Vec::new();
141    let mut search_from = 0;
142
143    while let Some(start) = wikitext[search_from..].find(&marker) {
144        let abs_start = search_from + start;
145
146        // Encontra o }} de fechamento respeitando aninhamento
147        let content_start = abs_start + 2; // após {{
148        let mut depth = 1usize;
149        let mut pos = content_start;
150        let bytes = wikitext.as_bytes();
151
152        while pos < bytes.len().saturating_sub(1) && depth > 0 {
153            if bytes[pos] == b'{' && bytes[pos + 1] == b'{' {
154                depth += 1;
155                pos += 2;
156            } else if bytes[pos] == b'}' && bytes[pos + 1] == b'}' {
157                depth -= 1;
158                if depth == 0 {
159                    // Inclui o conteúdo sem as {{ e }}
160                    blocks.push(&wikitext[content_start..pos]);
161                    search_from = pos + 2;
162                    break;
163                }
164                pos += 2;
165            } else {
166                pos += 1;
167            }
168        }
169
170        if depth > 0 {
171            // Bloco sem fechamento — para de buscar
172            break;
173        }
174    }
175
176    blocks
177}
178
179/// Extrai todos os parâmetros `|chave = valor` de um bloco de template.
180/// Retorna um mapa de chave normalizada (lowercase, trim) → valor (trim).
181fn parse_params(block: &str) -> std::collections::HashMap<String, String> {
182    let mut map = std::collections::HashMap::new();
183
184    for cap in RE_PARAM.captures_iter(block) {
185        let key = cap[1].trim().to_lowercase();
186        let value = cap[2].trim().to_string();
187
188        // Ignora linhas de notas e refs — só queremos os valores primários
189        if key.ends_with(" notes") || key == "ref" {
190            continue;
191        }
192
193        // Ignora valores vazios
194        if !value.is_empty() {
195            map.insert(key, value);
196        }
197    }
198
199    map
200}
201
202/// Retorna `Some(value)` se a chave existe e o valor não é vazio.
203fn get_param(params: &std::collections::HashMap<String, String>, key: &str) -> Option<String> {
204    params.get(key).cloned()
205}
206
207/// Combina dois campos opcionais em uma string "valor1 / valor2" quando ambos
208/// existem, ou retorna apenas o primeiro. Usado para `minCPU` + `minCPU2`.
209fn combine_fields(primary: Option<String>, secondary: Option<String>) -> Option<String> {
210    match (primary, secondary) {
211        (Some(a), Some(b)) if !b.is_empty() => Some(format!("{} / {}", a, b)),
212        (Some(a), _) => Some(a),
213        (None, Some(b)) => Some(b),
214        (None, None) => None,
215    }
216}
217
218/// Extrai todos os blocos `{{System requirements}}` do wikitext.
219///
220/// Retorna um vetor com uma entrada por bloco encontrado. Cada bloco
221/// representa um OS (`OSfamily`) e opcionalmente um tier extra (`alt1`, `alt2`).
222///
223/// # Tiers extras (Witcher 3 style)
224///
225/// Quando `alt1Title` está presente, o bloco padrão (min/rec) é retornado como
226/// primeiro elemento e os tiers extras como elementos adicionais. Isso permite
227/// ao frontend exibir "Mínimo / Recomendado / High / Ultra" separadamente.
228pub fn parse_system_requirements(wikitext: &str, steam_app_id: &str) -> Vec<SystemRequirements> {
229    let blocks = extract_template_blocks(wikitext, "System requirements");
230    let mut all_reqs = Vec::new();
231
232    for block in blocks {
233        let params = parse_params(block);
234
235        let os_family = get_param(&params, "osfamily").unwrap_or_else(|| "Unknown".to_string());
236
237        // --- Requisitos padrão (min / rec) ---
238        let standard = SystemRequirements {
239            steam_app_id: steam_app_id.to_string(),
240            os_family: os_family.clone(),
241            tier_title: None,
242            target: get_param(&params, "mintgt"),
243            min_os: get_param(&params, "minos"),
244            min_cpu: combine_fields(get_param(&params, "mincpu"), get_param(&params, "mincpu2")),
245            min_cpu2: None, // já combinado acima
246            min_ram: get_param(&params, "minram"),
247            min_gpu: combine_fields(get_param(&params, "mingpu"), get_param(&params, "mingpu2")),
248            min_gpu2: None,
249            min_vram: get_param(&params, "minvram"),
250            min_dx: get_param(&params, "mindx"),
251            min_storage: get_param(&params, "minhd"),
252            rec_os: get_param(&params, "recos"),
253            rec_cpu: combine_fields(get_param(&params, "reccpu"), get_param(&params, "reccpu2")),
254            rec_cpu2: None,
255            rec_ram: get_param(&params, "recram"),
256            rec_gpu: combine_fields(get_param(&params, "recgpu"), get_param(&params, "recgpu2")),
257            rec_gpu2: None,
258            rec_vram: get_param(&params, "recvram"),
259            rec_dx: get_param(&params, "recdx"),
260            rec_storage: get_param(&params, "rechd"),
261        };
262        all_reqs.push(standard);
263
264        // --- Tiers extras: alt1, alt2, alt3 (Witcher 3 style) ---
265        for n in 1..=3u8 {
266            let prefix = format!("alt{}", n);
267            let title_key = format!("{}title", prefix);
268
269            // Tier só existe se tiver título
270            let tier_title = match get_param(&params, &title_key) {
271                Some(t) => t,
272                None => break,
273            };
274
275            let alt = SystemRequirements {
276                steam_app_id: steam_app_id.to_string(),
277                os_family: os_family.clone(),
278                tier_title: Some(tier_title),
279                target: get_param(&params, &format!("{}tgt", prefix)),
280                min_os: get_param(&params, &format!("{}os", prefix)),
281                min_cpu: combine_fields(
282                    get_param(&params, &format!("{}cpu", prefix)),
283                    get_param(&params, &format!("{}cpu2", prefix)),
284                ),
285                min_cpu2: None,
286                min_ram: get_param(&params, &format!("{}ram", prefix)),
287                min_gpu: combine_fields(
288                    get_param(&params, &format!("{}gpu", prefix)),
289                    get_param(&params, &format!("{}gpu2", prefix)),
290                ),
291                min_gpu2: None,
292                min_vram: get_param(&params, &format!("{}vram", prefix)),
293                min_dx: get_param(&params, &format!("{}dx", prefix)),
294                min_storage: get_param(&params, &format!("{}hd", prefix)),
295                // Tiers extras só têm mínimo — sem rec separado
296                rec_os: None,
297                rec_cpu: None,
298                rec_cpu2: None,
299                rec_ram: None,
300                rec_gpu: None,
301                rec_gpu2: None,
302                rec_vram: None,
303                rec_dx: None,
304                rec_storage: None,
305            };
306            all_reqs.push(alt);
307        }
308    }
309
310    all_reqs
311}
312
313/// Extrai todos os caminhos de save e config do wikitext.
314///
315/// Cada `{{Game data/config|OS|path}}` e `{{Game data/saves|OS|path}}` gera
316/// uma entrada. Os caminhos preservam a sintaxe `{{p|variavel}}` intacta.
317///
318/// OS normalizado: "Windows", "Linux", "OS X" (como aparece na PCGW).
319pub fn parse_game_data_paths(wikitext: &str, steam_app_id: &str) -> Vec<GameDataPath> {
320    let mut paths = Vec::new();
321
322    for cap in RE_GAME_DATA_START.captures_iter(wikitext) {
323        let kind = cap[1].trim().to_string();
324        let os = cap[2].trim().to_string();
325
326        // Posição logo após o | que precede o path
327        let match_end = cap.get(0).unwrap().end();
328        let rest = &wikitext[match_end..];
329
330        let raw_path = match extract_path_arg(rest) {
331            Some(p) if !p.is_empty() => p.to_string(),
332            _ => continue,
333        };
334
335        paths.push(GameDataPath {
336            steam_app_id: steam_app_id.to_string(),
337            kind,
338            os,
339            raw_path,
340            expanded_path: None,
341        });
342    }
343
344    paths
345}
346
347/// Faz o scraping completo de uma página do PCGamingWiki pelo `page_id`.
348///
349/// Orquestra:
350/// 1. Busca do wikitext via `fetch_wikitext`
351/// 2. Parse dos blocos `{{System requirements}}`
352/// 3. Parse dos blocos `{{Game data/config}}` e `{{Game data/saves}}`
353///
354/// Retorna `PcgwScrapedData` com todos os dados extraídos.
355/// Em caso de falha de rede, propaga `AppError::NetworkError` para que o
356/// chamador trate graciosamente (offline-first: nenhum dado parcial é salvo).
357pub async fn scrape_pcgw_page(
358    client: &reqwest::Client,
359    page_id: &str,
360    steam_app_id: &str,
361) -> Result<PcgwScrapedData, AppError> {
362    let wikitext = fetch_wikitext(client, page_id).await?;
363
364    let system_requirements = parse_system_requirements(&wikitext, steam_app_id);
365    let game_data_paths = parse_game_data_paths(&wikitext, steam_app_id);
366
367    tracing::debug!(
368        "PCGW scraper page_id={}: {} blocos de sysreq, {} paths de game data",
369        page_id,
370        system_requirements.len(),
371        game_data_paths.len()
372    );
373
374    Ok(PcgwScrapedData {
375        system_requirements,
376        game_data_paths,
377    })
378}