@@ -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.
5858const 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+
6770pub 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