diff --git a/crates/crw-renderer/src/lib.rs b/crates/crw-renderer/src/lib.rs index 99131e33..056f6b16 100644 --- a/crates/crw-renderer/src/lib.rs +++ b/crates/crw-renderer/src/lib.rs @@ -3430,6 +3430,27 @@ impl FallbackRenderer { ); result.warnings.push(hint); } + // A wall no tier could clear is not content. Every tier already + // classified it — that verdict is exactly why the result landed in + // `thin_result` instead of being accepted — and this was the one + // place that discarded it: the interstitial went back stamped with a + // page credit under `success: true`, so the caller paid for + // "Checking your browser" and an agent read it as the page. + // + // Scoped deliberately to the generic phrase-list wall. A CF + // challenge or a named vendor shell keeps shipping as a thin result: + // those have their own recovery arms (cloak, chrome_proxy) that read + // the body back, and `cloak_recover_on_cf_false_skips_arm_on_exhausted_deadline` + // pins that shape. A genuinely thin but real page still ships too, + // because returning the best available body is the point of this + // tail; the recall invariant depends on that staying true. + if detector::looks_like_generic_bot_wall(&result.html, result.truncated) { + return Err(CrwError::HttpError(format!( + "blocked by an anti-bot wall that none of the {} renderer tier(s) \ + attempted could clear", + chain.len().max(1), + ))); + } Ok(result) } else { Err(last_error @@ -4862,6 +4883,94 @@ mod tests { )); } + /// Prod shipped an anti-bot interstitial to a paying customer as + /// `success: true` with `creditCost: 1`, because the ladder tail returned + /// the best thin result without re-reading the verdict every tier had + /// already reached. Live case: https://www.prlib.ru/en/history/619410 on + /// 2026-09-03, "Security Check / Checking your browser", 95 chars. + #[tokio::test] + async fn wall_the_whole_ladder_failed_to_clear_is_not_a_success() { + let wall = "
Checking your browser before accessing the site
"; + let lp = Arc::new(MockFetcher { + name: "lightpanda", + behavior: MockBehavior::Ok(wall.to_string()), + }) as ArcShort but genuine page.
"; + let lp = Arc::new(MockFetcher { + name: "lightpanda", + behavior: MockBehavior::Ok(thin.to_string()), + }) as Arc