game_manager_lib/services/integration/pcgamingwiki/
scraper.rs1use crate::errors::AppError;
33use crate::models::{GameDataPath, PcgwScrapedData, SystemRequirements};
34use once_cell::sync::Lazy;
35use regex::Regex;
36
37static RE_PARAM: Lazy<Regex> =
43 Lazy::new(|| Regex::new(r"\|([^=|}\n]+?)\s*=\s*([^|}\n]*)").expect("RE_PARAM: regex inválida"));
44
45static 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
52fn extract_path_arg(rest: &str) -> Option<&str> {
58 let bytes = rest.as_bytes();
59 let mut depth = 1usize; 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 return Some(rest[path_start..pos].trim());
72 }
73 pos += 2;
74 } else if bytes[pos] == b'|' && depth == 1 {
75 return Some(rest[path_start..pos].trim());
77 } else {
78 pos += 1;
79 }
80 }
81
82 None
83}
84
85pub 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 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
132fn 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 let content_start = abs_start + 2; 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 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 break;
173 }
174 }
175
176 blocks
177}
178
179fn 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 if key.ends_with(" notes") || key == "ref" {
190 continue;
191 }
192
193 if !value.is_empty() {
195 map.insert(key, value);
196 }
197 }
198
199 map
200}
201
202fn get_param(params: &std::collections::HashMap<String, String>, key: &str) -> Option<String> {
204 params.get(key).cloned()
205}
206
207fn 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
218pub 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(¶ms, "osfamily").unwrap_or_else(|| "Unknown".to_string());
236
237 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(¶ms, "mintgt"),
243 min_os: get_param(¶ms, "minos"),
244 min_cpu: combine_fields(get_param(¶ms, "mincpu"), get_param(¶ms, "mincpu2")),
245 min_cpu2: None, min_ram: get_param(¶ms, "minram"),
247 min_gpu: combine_fields(get_param(¶ms, "mingpu"), get_param(¶ms, "mingpu2")),
248 min_gpu2: None,
249 min_vram: get_param(¶ms, "minvram"),
250 min_dx: get_param(¶ms, "mindx"),
251 min_storage: get_param(¶ms, "minhd"),
252 rec_os: get_param(¶ms, "recos"),
253 rec_cpu: combine_fields(get_param(¶ms, "reccpu"), get_param(¶ms, "reccpu2")),
254 rec_cpu2: None,
255 rec_ram: get_param(¶ms, "recram"),
256 rec_gpu: combine_fields(get_param(¶ms, "recgpu"), get_param(¶ms, "recgpu2")),
257 rec_gpu2: None,
258 rec_vram: get_param(¶ms, "recvram"),
259 rec_dx: get_param(¶ms, "recdx"),
260 rec_storage: get_param(¶ms, "rechd"),
261 };
262 all_reqs.push(standard);
263
264 for n in 1..=3u8 {
266 let prefix = format!("alt{}", n);
267 let title_key = format!("{}title", prefix);
268
269 let tier_title = match get_param(¶ms, &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(¶ms, &format!("{}tgt", prefix)),
280 min_os: get_param(¶ms, &format!("{}os", prefix)),
281 min_cpu: combine_fields(
282 get_param(¶ms, &format!("{}cpu", prefix)),
283 get_param(¶ms, &format!("{}cpu2", prefix)),
284 ),
285 min_cpu2: None,
286 min_ram: get_param(¶ms, &format!("{}ram", prefix)),
287 min_gpu: combine_fields(
288 get_param(¶ms, &format!("{}gpu", prefix)),
289 get_param(¶ms, &format!("{}gpu2", prefix)),
290 ),
291 min_gpu2: None,
292 min_vram: get_param(¶ms, &format!("{}vram", prefix)),
293 min_dx: get_param(¶ms, &format!("{}dx", prefix)),
294 min_storage: get_param(¶ms, &format!("{}hd", prefix)),
295 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
313pub 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 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
347pub 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}