crawlberg 1.0.5

High-performance web crawling engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
//! Integration tests for the HTTP → Bypass → Browser dispatch chain.
//!
//! Each test instantiates a wiremock server, configures a `CrawlEngine` with a
//! specific `EscalationStrategy` (and optionally a `CountingMockProvider`), and
//! asserts chain behaviour against stubbed responses.
//!
//! The browser tier is not exercised here — browser tests require the `browser`
//! feature and a live Chrome instance.

use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

use async_trait::async_trait;
use crawlberg::{
    AttemptOutcome, BudgetExhausted, BypassProvider, BypassResponse, CrawlConfig, CrawlEngine, CrawlError,
    DispatchProfile, EscalationBudget, EscalationStrategy, RetryDirective, RetryPolicy,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

/// Bypass provider that returns a canned response and counts calls.
#[derive(Debug)]
struct CountingMockProvider {
    response: BypassResponse,
    calls: AtomicU32,
}

impl CountingMockProvider {
    fn new(body: &str) -> Arc<Self> {
        Arc::new(Self {
            response: BypassResponse {
                status: 200,
                content_type: "text/html".into(),
                body: format!("<html><body>{body}</body></html>"),
                body_bytes: format!("<html><body>{body}</body></html>").into_bytes(),
                headers: Default::default(),
                final_url: String::new(),
                cost_usd: Some(0.0015),
                vendor_request_id: None,
            },
            calls: AtomicU32::new(0),
        })
    }

    fn calls(&self) -> u32 {
        self.calls.load(Ordering::SeqCst)
    }
}

#[async_trait]
impl BypassProvider for CountingMockProvider {
    async fn fetch(&self, _url: &str) -> Result<BypassResponse, CrawlError> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        Ok(self.response.clone())
    }

    fn vendor_name(&self) -> &'static str {
        "mock"
    }
}

fn build_engine(config: CrawlConfig) -> CrawlEngine {
    CrawlEngine::builder().config(config).build().unwrap()
}

/// Extract the markdown text content from a `ScrapeResult`, returning an empty
/// string when no markdown was produced.
fn markdown_content(result: &crawlberg::ScrapeResult) -> &str {
    result.markdown.as_ref().map(|m| m.content.as_str()).unwrap_or("")
}

fn config_with(strategy: EscalationStrategy, provider: Option<Arc<CountingMockProvider>>) -> CrawlConfig {
    CrawlConfig {
        dispatch: Some(DispatchProfile {
            strategy,
            bypass: provider.map(|p| p as _),
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    }
}

/// HTTP success with `BypassThenBrowser` does not call bypass.
#[tokio::test]
async fn http_success_does_not_escalate() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/ok"))
        .respond_with(ResponseTemplate::new(200).set_body_string("<html>ok</html>"))
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("from bypass");
    let engine = build_engine(config_with(
        EscalationStrategy::BypassThenBrowser,
        Some(provider.clone()),
    ));

    let result = engine.scrape(&format!("{}/ok", mock.uri())).await.unwrap();
    assert!(markdown_content(&result).contains("ok"), "expected 'ok' in markdown");
    assert_eq!(provider.calls(), 0, "bypass must not be called on HTTP success");
}

/// HTTP success on the default path (no bypass configured, strategy BrowserOnly).
#[tokio::test]
async fn http_success_browser_only_strategy() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/page"))
        .respond_with(ResponseTemplate::new(200).set_body_string("<html>browser-only ok</html>"))
        .mount(&mock)
        .await;

    let engine = build_engine(config_with(EscalationStrategy::BrowserOnly, None));
    let result = engine.scrape(&format!("{}/page", mock.uri())).await.unwrap();
    assert!(
        markdown_content(&result).contains("browser-only ok"),
        "expected page content in markdown"
    );
}

/// WAF-blocked HTTP escalates to bypass under `BypassOnly`.
#[tokio::test]
async fn waf_block_escalates_http_to_bypass() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/blocked"))
        .respond_with(
            ResponseTemplate::new(403)
                .insert_header("server", "cloudflare")
                .set_body_string("<html><head><title>Just a moment...</title></head></html>"),
        )
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("vendor-fetched content");
    let engine = build_engine(config_with(EscalationStrategy::BypassOnly, Some(provider.clone())));

    let result = engine.scrape(&format!("{}/blocked", mock.uri())).await.unwrap();
    let markdown = markdown_content(&result);
    assert!(
        markdown.contains("vendor-fetched content"),
        "expected bypass content in markdown, got: {markdown:?}"
    );
    assert_eq!(provider.calls(), 1, "bypass must be called exactly once");
}

/// Explicit `BypassFirst` strategy routes all fetches through the bypass provider.
///
/// Callers must now set `DispatchProfile.strategy = BypassFirst` explicitly —
/// the pre-1.5.12 auto-promotion of `(BrowserOnly + bypass.is_some())` to
/// `BypassFirst` has been removed.
#[tokio::test]
async fn bypass_first_explicit_strategy_routes_through_bypass() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/x"))
        .respond_with(ResponseTemplate::new(200).set_body_string("<html>http response</html>"))
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("bypass response");

    let config = CrawlConfig {
        dispatch: Some(DispatchProfile {
            strategy: EscalationStrategy::BypassFirst,
            bypass: Some(provider.clone() as _),
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    };

    let engine = build_engine(config);
    let result = engine.scrape(&format!("{}/x", mock.uri())).await.unwrap();
    let markdown = markdown_content(&result);
    assert!(
        markdown.contains("bypass response"),
        "BypassFirst strategy must route through bypass; got: {markdown:?}"
    );
    assert_eq!(provider.calls(), 1, "bypass must be called exactly once");
}

/// `EscalationStrategy::None` propagates HTTP errors without escalation.
#[tokio::test]
async fn none_strategy_propagates_http_errors() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/forbidden"))
        .respond_with(ResponseTemplate::new(403).set_body_string("nope"))
        .mount(&mock)
        .await;

    let engine = build_engine(config_with(EscalationStrategy::None, None));
    let err = engine.scrape(&format!("{}/forbidden", mock.uri())).await.unwrap_err();
    assert!(
        matches!(err, CrawlError::Forbidden(_) | CrawlError::WafBlocked { .. }),
        "expected Forbidden or WafBlocked, got: {err:?}"
    );
}

/// `BypassThenBrowser` skips browser when bypass succeeds after HTTP WAF block.
#[tokio::test]
async fn bypass_then_browser_skips_browser_when_bypass_succeeds() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/cf"))
        .respond_with(
            ResponseTemplate::new(403)
                .insert_header("server", "cloudflare")
                .set_body_string("<html>Just a moment...</html>"),
        )
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("vendor content");
    let engine = build_engine(config_with(
        EscalationStrategy::BypassThenBrowser,
        Some(provider.clone()),
    ));

    let result = engine.scrape(&format!("{}/cf", mock.uri())).await.unwrap();
    let markdown = markdown_content(&result);
    assert!(
        markdown.contains("vendor content"),
        "expected bypass content, got: {markdown:?}"
    );
    assert_eq!(provider.calls(), 1, "bypass success must terminate chain");
}

/// `BypassOnly` with no bypass provider configured returns an error rather than panicking.
#[tokio::test]
async fn bypass_only_without_provider_returns_error() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/any"))
        .respond_with(
            ResponseTemplate::new(403)
                .insert_header("server", "cloudflare")
                .set_body_string("<html>blocked</html>"),
        )
        .mount(&mock)
        .await;

    let engine = build_engine(config_with(EscalationStrategy::BypassOnly, None));
    let err = engine.scrape(&format!("{}/any", mock.uri())).await.unwrap_err();
    assert!(
        matches!(
            err,
            CrawlError::Forbidden(_)
                | CrawlError::WafBlocked { .. }
                | CrawlError::InvalidConfig(_)
                | CrawlError::Other(_)
        ),
        "expected escalation-related error, got: {err:?}"
    );
}

/// Budget exhaustion prevents escalation even on WAF block.
#[tokio::test]
async fn zero_budget_prevents_escalation() {
    #[derive(Debug)]
    struct ZeroBudget;

    #[async_trait]
    impl EscalationBudget for ZeroBudget {
        async fn try_consume(&self, _cost_cents: u32) -> Result<(), BudgetExhausted> {
            Err(BudgetExhausted)
        }
    }

    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/cf"))
        .respond_with(
            ResponseTemplate::new(403)
                .insert_header("server", "cloudflare")
                .set_body_string("<html>blocked</html>"),
        )
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("bypass would succeed");
    let cfg = CrawlConfig {
        dispatch: Some(DispatchProfile {
            strategy: EscalationStrategy::BypassThenBrowser,
            bypass: Some(provider.clone() as _),
            escalation_budget: Some(Arc::new(ZeroBudget)),
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    };

    let engine = build_engine(cfg);
    let err = engine.scrape(&format!("{}/cf", mock.uri())).await.unwrap_err();

    assert!(
        matches!(err, CrawlError::Forbidden(_) | CrawlError::WafBlocked { .. }),
        "budget exhaustion must surface HTTP error; got: {err:?}"
    );
    assert_eq!(provider.calls(), 0, "bypass must not be called when budget exhausted");
}

/// Unlimited budget always allows escalation.
#[tokio::test]
async fn unlimited_budget_allows_escalation() {
    #[derive(Debug)]
    struct AlwaysOkBudget;

    #[async_trait]
    impl EscalationBudget for AlwaysOkBudget {
        async fn try_consume(&self, _cost_cents: u32) -> Result<(), BudgetExhausted> {
            Ok(())
        }
    }

    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/cf"))
        .respond_with(
            ResponseTemplate::new(403)
                .insert_header("server", "cloudflare")
                .set_body_string("<html>Just a moment...</html>"),
        )
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("bypass ok");
    let cfg = CrawlConfig {
        dispatch: Some(DispatchProfile {
            strategy: EscalationStrategy::BypassOnly,
            bypass: Some(provider.clone() as _),
            escalation_budget: Some(Arc::new(AlwaysOkBudget)),
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    };

    let engine = build_engine(cfg);
    let result = engine.scrape(&format!("{}/cf", mock.uri())).await.unwrap();
    let markdown = markdown_content(&result);
    assert!(
        markdown.contains("bypass ok"),
        "expected bypass content with unlimited budget, got: {markdown:?}"
    );
    assert_eq!(provider.calls(), 1);
}

/// Custom `RetryPolicy` that always returns `Retry { backoff_ms: 0 }`.
/// Used to prove the engine's global cap (`max_total_attempts`) terminates
/// the dispatch loop rather than spinning forever.
#[derive(Debug)]
struct AlwaysRetryPolicy;

#[async_trait]
impl RetryPolicy for AlwaysRetryPolicy {
    async fn decide(&self, _outcome: &AttemptOutcome) -> RetryDirective {
        RetryDirective::Retry { backoff_ms: 0 }
    }

    fn name(&self) -> &'static str {
        "always_retry"
    }
}

/// A buggy `RetryPolicy` returning `Retry` forever must be stopped by the
/// engine's `max_total_attempts` cap within a bounded wall-clock time.
#[tokio::test]
async fn buggy_policy_returning_retry_forever_does_not_spin() {
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/timeout"))
        .respond_with(ResponseTemplate::new(500))
        .mount(&mock)
        .await;

    let config = CrawlConfig {
        dispatch: Some(DispatchProfile {
            strategy: EscalationStrategy::BrowserOnly,
            retry_policy: Some(Arc::new(AlwaysRetryPolicy)),
            max_total_attempts: 5,
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    };

    let engine = build_engine(config);

    let start = std::time::Instant::now();
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(5),
        engine.scrape(&format!("{}/timeout", mock.uri())),
    )
    .await;
    let elapsed = start.elapsed();

    assert!(
        result.is_ok(),
        "engine spun past 5s — max_total_attempts cap did not fire; elapsed = {elapsed:?}"
    );
}

/// Wiremock returns HTTP 200 with a Cloudflare Turnstile challenge HTML body.
/// With `waf_classifier` wired, the dispatcher must detect the block-page and
/// escalate to the bypass tier. Without the wiring (Session 1 pre-fix state),
/// the dispatcher would silently return the challenge HTML as extracted content.
#[tokio::test]
async fn turnstile_challenge_html_triggers_escalation() {
    let mock = MockServer::start().await;
    let challenge_html = concat!(
        "<!DOCTYPE html><html><head><title>Just a moment...</title></head>",
        "<body><script src=\"/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1\"></script>",
        "Please verify you are human.</body></html>"
    );
    Mock::given(method("GET"))
        .and(path("/cf-turnstile"))
        .respond_with(
            ResponseTemplate::new(200)
                .insert_header("server", "cloudflare")
                .set_body_string(challenge_html),
        )
        .mount(&mock)
        .await;

    let provider = CountingMockProvider::new("vendor-fetched content");
    let config = CrawlConfig {
        dispatch: Some(DispatchProfile {
            strategy: EscalationStrategy::BypassOnly,
            bypass: Some(provider.clone() as _),
            waf_classifier: Some(Arc::new(crawlberg::TomlClassifier::builtin())),
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    };

    let engine = build_engine(config);
    let result = engine.scrape(&format!("{}/cf-turnstile", mock.uri())).await.unwrap();
    let markdown = markdown_content(&result);
    assert!(
        markdown.contains("vendor-fetched"),
        "engine must escalate to bypass when 200 returns a CF challenge; got: {markdown:?}"
    );
    assert_eq!(provider.calls(), 1, "bypass must be called exactly once");
}

/// Wiremock returns an HTML body with meaningful text content.
/// The retry policy records the `content_density` from `AttemptOutcome` and the
/// test asserts that it is in the expected range (> 0.2) rather than being the
/// previously hardcoded 0.0.
#[tokio::test]
async fn content_density_populated_for_html_response() {
    use std::sync::Mutex;

    let mock = MockServer::start().await;
    let html_body = "<html><body><p>Hello world</p><p>More content here</p></body></html>";
    Mock::given(method("GET"))
        .and(path("/dense"))
        .respond_with(ResponseTemplate::new(200).set_body_string(html_body))
        .mount(&mock)
        .await;

    #[derive(Debug)]
    struct RecordingPolicy(Arc<Mutex<f32>>);

    #[async_trait::async_trait]
    impl RetryPolicy for RecordingPolicy {
        async fn decide(&self, outcome: &AttemptOutcome) -> RetryDirective {
            *self.0.lock().unwrap() = outcome.content_density;
            RetryDirective::Stop
        }

        fn name(&self) -> &'static str {
            "recording"
        }
    }

    let recorded = Arc::new(Mutex::new(-1.0_f32));
    let config = CrawlConfig {
        dispatch: Some(DispatchProfile {
            retry_policy: Some(Arc::new(RecordingPolicy(recorded.clone()))),
            ..DispatchProfile::default()
        }),
        ..CrawlConfig::default()
    };
    let engine = build_engine(config);
    let _ = engine.scrape(&format!("{}/dense", mock.uri())).await.unwrap();

    let observed = *recorded.lock().unwrap();
    assert!(
        observed > 0.2 && observed < 0.8,
        "expected content_density in (0.2, 0.8) for an HTML body with real text, got {observed}"
    );
}