Skip to content

Commit e95843c

Browse files
committed
fix(service): treat screenscraper header.success=false and non-trouvée bodies as misses not errors
1 parent 94468fb commit e95843c

1 file changed

Lines changed: 63 additions & 25 deletions

File tree

  • service/src/providers/screenscraper

service/src/providers/screenscraper/mod.rs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,17 @@ const QUOTA_BLOCK_TTL_SECS: i64 = 24 * 60 * 60;
5656
/// parse a body as JSON because incident pages come back with HTTP 200.
5757
/// Sourced from the Skyscraper project.
5858
const INCIDENT_PHRASES: &[&str] = &[
59-
"non trouvée",
6059
"API totalement fermé",
6160
"blacklisté",
6261
"Votre quota de scrape est",
6362
"API fermé pour les non membres",
6463
"maximum threads",
6564
];
6665

66+
/// Body fragments ScreenScraper returns on a per-request miss. Routed to
67+
/// `Ok(None)` so the matcher records a miss instead of bailing.
68+
const NOT_FOUND_PHRASES: &[&str] = &["non trouvée"];
69+
6770
pub struct ScreenScraperClient {
6871
client: Client,
6972
service: Mutex<Retry<RetryPolicy, Client>>,
@@ -170,10 +173,15 @@ impl ScreenScraperClient {
170173
("recherche", trimmed.to_string()),
171174
],
172175
)?;
176+
// `header.success="false"` on jeuRecherche.php is "no matches", not an
177+
// error; treat as an empty page so the matcher's bottom
178+
// `write_auto_match_failed` still fires.
173179
let env = self
174-
.do_get_envelope::<JeuxPayload>("game_search", url)
180+
.do_get_envelope_optional::<JeuxPayload>("game_search", url)
175181
.await?;
176-
Ok(env.response.map(|r| r.payload.jeux).unwrap_or_default())
182+
Ok(env
183+
.and_then(|env| env.response.map(|r| r.payload.jeux))
184+
.unwrap_or_default())
177185
}
178186

179187
pub async fn get_game_by_id(&self, game_id: i64) -> anyhow::Result<Option<SsGame>> {
@@ -360,26 +368,29 @@ impl ScreenScraperClient {
360368
body_preview(&body)
361369
)),
362370
_ if body.is_empty() => Ok(None),
363-
_ => {
364-
parse_or_incident(&body, content_type.as_deref())?;
365-
let env: SsEnvelope<T> = serde_json::from_str(&body)
366-
.with_context(|| "failed to parse screenscraper response envelope")?;
367-
let header_signals_failure = env
368-
.header
369-
.as_ref()
370-
.is_some_and(|h| h.success.eq_ignore_ascii_case("false"));
371-
if let Some(resp) = env.response.as_ref() {
372-
self.update_quota_from(&resp.ssuser);
373-
self.update_concurrency_from(&resp.ssuser);
374-
}
375-
if header_signals_failure {
376-
return Err(anyhow!(
377-
"screenscraper header reported failure: {:?}",
378-
env.header.and_then(|h| h.error)
379-
));
371+
_ => match parse_or_incident(&body, content_type.as_deref())? {
372+
ParseOutcome::NotFound => Ok(None),
373+
ParseOutcome::Parseable => {
374+
let env: SsEnvelope<T> = serde_json::from_str(&body)
375+
.with_context(|| "failed to parse screenscraper response envelope")?;
376+
if let Some(resp) = env.response.as_ref() {
377+
self.update_quota_from(&resp.ssuser);
378+
self.update_concurrency_from(&resp.ssuser);
379+
}
380+
let header_signals_failure = env
381+
.header
382+
.as_ref()
383+
.is_some_and(|h| h.success.eq_ignore_ascii_case("false"));
384+
if header_signals_failure {
385+
debug!(
386+
"screenscraper header.success=false for {endpoint_label}: {:?}",
387+
env.header.and_then(|h| h.error)
388+
);
389+
return Ok(None);
390+
}
391+
Ok(Some(env))
380392
}
381-
Ok(Some(env))
382-
}
393+
},
383394
}
384395
}
385396

@@ -540,7 +551,12 @@ fn body_preview(body: &str) -> String {
540551
/// Returns `Err` when the body looks like a French incident message or the
541552
/// content type is not JSON, so the caller does not try to parse the body and
542553
/// the per-game match logs an error instead of aborting the whole cycle.
543-
fn parse_or_incident(body: &str, content_type: Option<&str>) -> anyhow::Result<()> {
554+
enum ParseOutcome {
555+
Parseable,
556+
NotFound,
557+
}
558+
559+
fn parse_or_incident(body: &str, content_type: Option<&str>) -> anyhow::Result<ParseOutcome> {
544560
if let Some(ct) = content_type
545561
&& !ct.to_ascii_lowercase().contains("application/json")
546562
{
@@ -551,6 +567,11 @@ fn parse_or_incident(body: &str, content_type: Option<&str>) -> anyhow::Result<(
551567

552568
let prefix: String = body.chars().take(1024).collect();
553569
let prefix_lower = prefix.to_lowercase();
570+
for phrase in NOT_FOUND_PHRASES {
571+
if prefix_lower.contains(&phrase.to_lowercase()) {
572+
return Ok(ParseOutcome::NotFound);
573+
}
574+
}
554575
for phrase in INCIDENT_PHRASES {
555576
if prefix_lower.contains(&phrase.to_lowercase()) {
556577
return Err(anyhow!(
@@ -559,7 +580,7 @@ fn parse_or_incident(body: &str, content_type: Option<&str>) -> anyhow::Result<(
559580
}
560581
}
561582

562-
Ok(())
583+
Ok(ParseOutcome::Parseable)
563584
}
564585

565586
#[async_trait::async_trait]
@@ -682,7 +703,24 @@ mod tests {
682703
#[test]
683704
fn parse_or_incident_accepts_valid_json_body() {
684705
let body = "{\"header\": {\"success\": \"true\"}}";
685-
assert!(parse_or_incident(body, Some("application/json")).is_ok());
706+
assert!(matches!(
707+
parse_or_incident(body, Some("application/json")),
708+
Ok(ParseOutcome::Parseable)
709+
));
710+
}
711+
712+
#[test]
713+
fn parse_or_incident_classifies_not_found_phrases_as_misses() {
714+
for phrase in NOT_FOUND_PHRASES {
715+
let body = format!("Erreur : ROM/ISO/Fichier {phrase} !");
716+
assert!(
717+
matches!(
718+
parse_or_incident(&body, Some("application/json")),
719+
Ok(ParseOutcome::NotFound)
720+
),
721+
"phrase {phrase} should classify as NotFound, got something else"
722+
);
723+
}
686724
}
687725

688726
#[test]

0 commit comments

Comments
 (0)