1use std::collections::BTreeMap;
6
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::{Map, Value};
9
10#[derive(Debug, Clone, PartialEq, Serialize)]
12pub struct Format {
13 #[serde(rename = "type")]
14 pub kind: String,
15 #[serde(flatten)]
16 pub options: Map<String, Value>,
17}
18
19impl Format {
20 pub fn new(kind: &str) -> Self {
21 Self {
22 kind: kind.to_string(),
23 options: Map::new(),
24 }
25 }
26}
27
28impl<'de> Deserialize<'de> for Format {
29 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
30 match Value::deserialize(d)? {
31 Value::String(kind) => Ok(Format::new(&kind)),
32 Value::Object(mut map) => {
33 let kind = match map.remove("type") {
34 Some(Value::String(k)) => k,
35 _ => {
36 return Err(serde::de::Error::custom(
37 "format object needs a string `type`",
38 ));
39 }
40 };
41 Ok(Format { kind, options: map })
42 }
43 _ => Err(serde::de::Error::custom(
44 "format must be a string or an object",
45 )),
46 }
47 }
48}
49
50fn default_formats() -> Vec<Format> {
51 vec![Format::new("markdown")]
52}
53
54fn yes() -> bool {
55 true
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
59pub struct Location {
60 #[serde(default)]
61 pub country: Option<String>,
62 #[serde(default)]
63 pub languages: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct ScrapeOptions {
70 #[serde(default = "default_formats")]
71 pub formats: Vec<Format>,
72 #[serde(default)]
73 pub headers: BTreeMap<String, String>,
74 #[serde(default)]
75 pub include_tags: Vec<String>,
76 #[serde(default)]
77 pub exclude_tags: Vec<String>,
78 #[serde(default = "yes")]
79 pub only_main_content: bool,
80 #[serde(default)]
82 pub timeout: Option<u64>,
83 #[serde(default)]
85 pub wait_for: u64,
86 #[serde(default)]
87 pub mobile: bool,
88 #[serde(default)]
89 pub actions: Vec<Value>,
90 #[serde(default)]
91 pub location: Option<Location>,
92 #[serde(default)]
93 pub parsers: Option<Value>,
94 #[serde(default = "yes")]
95 pub remove_base64_images: bool,
96 #[serde(default = "yes")]
97 pub block_ads: bool,
98 #[serde(default)]
100 pub proxy: Option<String>,
101 #[serde(default)]
104 pub egress: Option<String>,
105 #[serde(default)]
107 pub skip_tls_verification: Option<bool>,
108 #[serde(default)]
110 pub max_age: Option<u64>,
111 #[serde(default)]
113 pub min_age: Option<u64>,
114 #[serde(default = "yes")]
115 pub store_in_cache: bool,
116
117 #[serde(default)]
120 pub engine: Option<String>,
121 #[serde(default)]
122 pub if_none_match: Option<String>,
123 #[serde(default)]
124 pub if_modified_since: Option<String>,
125 #[serde(default)]
127 pub ignore_robots_txt: bool,
128 #[serde(default)]
129 pub store_snapshot: bool,
130 #[serde(default)]
132 pub query_filter: Option<QueryFilter>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub chunking: Option<Value>,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct QueryFilter {
150 pub query: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub threshold: Option<f32>,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub relative: Option<f32>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub top_k: Option<usize>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub max_tokens: Option<usize>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub min_tokens: Option<usize>,
166 pub headings: bool,
168}
169
170impl<'de> Deserialize<'de> for QueryFilter {
171 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
172 #[derive(Deserialize)]
173 #[serde(rename_all = "camelCase")]
174 struct Full {
175 query: String,
176 #[serde(default)]
177 threshold: Option<f32>,
178 #[serde(default)]
179 relative: Option<f32>,
180 #[serde(default)]
181 top_k: Option<usize>,
182 #[serde(default)]
183 max_tokens: Option<usize>,
184 #[serde(default)]
185 min_tokens: Option<usize>,
186 #[serde(default = "yes")]
187 headings: bool,
188 }
189 #[derive(Deserialize)]
190 #[serde(untagged)]
191 enum Repr {
192 Query(String),
193 Full(Full),
194 }
195 Ok(match Repr::deserialize(d)? {
196 Repr::Query(query) => QueryFilter {
197 query,
198 threshold: None,
199 relative: Some(pagesift_extract_defaults::RELATIVE),
200 top_k: None,
201 max_tokens: None,
202 min_tokens: Some(pagesift_extract_defaults::MIN_TOKENS),
203 headings: true,
204 },
205 Repr::Full(f) => QueryFilter {
206 query: f.query,
207 relative: f.relative.or(if f.threshold.is_none() {
208 Some(pagesift_extract_defaults::RELATIVE)
209 } else {
210 None
211 }),
212 threshold: f.threshold,
213 top_k: f.top_k,
214 max_tokens: f.max_tokens,
215 min_tokens: f.min_tokens.or(if f.threshold.is_none() {
216 Some(pagesift_extract_defaults::MIN_TOKENS)
217 } else {
218 None
219 }),
220 headings: f.headings,
221 },
222 })
223 }
224}
225
226mod pagesift_extract_defaults {
228 pub const RELATIVE: f32 = 0.5;
229 pub const MIN_TOKENS: usize = 1000;
230}
231
232impl Default for ScrapeOptions {
233 fn default() -> Self {
234 serde_json::from_value(Value::Object(Map::new())).expect("defaults deserialize")
235 }
236}
237
238impl ScrapeOptions {
239 pub fn wants(&self, kind: &str) -> bool {
240 self.formats.iter().any(|f| f.kind == kind)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct ScrapeRequest {
246 pub url: String,
247 #[serde(flatten)]
248 pub options: ScrapeOptions,
249}
250
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct BatchScrapeRequest {
254 pub urls: Vec<String>,
255 #[serde(default = "yes")]
256 pub ignore_invalid_urls: bool,
257 #[serde(default)]
258 pub max_concurrency: Option<usize>,
259 #[serde(default)]
260 pub webhook: Option<Value>,
261 #[serde(flatten)]
262 pub options: ScrapeOptions,
263}
264
265fn default_crawl_limit() -> usize {
266 10_000
267}
268
269fn default_sitemap() -> String {
270 "include".into()
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct CrawlRequest {
276 pub url: String,
277 #[serde(default)]
278 pub include_paths: Vec<String>,
279 #[serde(default)]
280 pub exclude_paths: Vec<String>,
281 #[serde(default)]
282 pub max_discovery_depth: Option<usize>,
283 #[serde(default = "default_crawl_limit")]
284 pub limit: usize,
285 #[serde(default)]
286 pub crawl_entire_domain: bool,
287 #[serde(default)]
288 pub allow_external_links: bool,
289 #[serde(default)]
290 pub allow_subdomains: bool,
291 #[serde(default)]
292 pub ignore_robots_txt: bool,
293 #[serde(default = "default_sitemap")]
294 pub sitemap: String,
295 #[serde(default)]
296 pub ignore_query_parameters: bool,
297 #[serde(default)]
299 pub delay: Option<f64>,
300 #[serde(default)]
301 pub max_concurrency: Option<usize>,
302 #[serde(default)]
303 pub scrape_options: ScrapeOptions,
304 #[serde(default)]
305 pub webhook: Option<Value>,
306
307 #[serde(default, deserialize_with = "query_or_keywords")]
311 pub query: Option<String>,
312 #[serde(default, deserialize_with = "adaptive_options")]
314 pub adaptive: Option<AdaptiveOptions>,
315}
316
317fn query_or_keywords<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
318 #[derive(Deserialize)]
319 #[serde(untagged)]
320 enum Repr {
321 Query(String),
322 Keywords(Vec<String>),
323 }
324 Ok(match Option::<Repr>::deserialize(d)? {
325 Some(Repr::Query(q)) => Some(q),
326 Some(Repr::Keywords(k)) => Some(k.join(" ")),
327 None => None,
328 }
329 .filter(|q| !q.trim().is_empty()))
330}
331
332fn adaptive_options<'de, D: Deserializer<'de>>(d: D) -> Result<Option<AdaptiveOptions>, D::Error> {
333 #[derive(Deserialize)]
334 #[serde(untagged)]
335 enum Repr {
336 Flag(bool),
337 Options(AdaptiveOptions),
338 }
339 Ok(match Option::<Repr>::deserialize(d)? {
340 Some(Repr::Flag(true)) => Some(AdaptiveOptions::default()),
341 Some(Repr::Flag(false)) | None => None,
342 Some(Repr::Options(o)) => Some(o),
343 })
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase", default)]
354pub struct AdaptiveOptions {
355 pub coverage_threshold: f64,
356 pub evidence_per_term: usize,
357 pub patience: usize,
358 pub min_gain: f64,
359}
360
361impl Default for AdaptiveOptions {
362 fn default() -> Self {
363 Self {
364 coverage_threshold: 0.9,
365 evidence_per_term: 3,
366 patience: 5,
367 min_gain: 0.05,
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase")]
375pub struct CrawlSummary {
376 pub stop_reason: String,
378 #[serde(skip_serializing_if = "Option::is_none")]
380 pub coverage: Option<f64>,
381 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
383 pub term_evidence: BTreeMap<String, usize>,
384 pub pages: usize,
386}
387
388fn default_map_limit() -> usize {
389 5_000
390}
391
392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct MapRequest {
395 pub url: String,
396 #[serde(default)]
397 pub search: Option<String>,
398 #[serde(default = "yes")]
399 pub include_subdomains: bool,
400 #[serde(default = "yes")]
401 pub ignore_query_parameters: bool,
402 #[serde(default = "default_sitemap")]
403 pub sitemap: String,
404 #[serde(default = "default_map_limit")]
405 pub limit: usize,
406 #[serde(default)]
407 pub ignore_robots_txt: bool,
408 #[serde(default)]
410 pub timeout: Option<u64>,
411}
412
413fn default_search_limit() -> usize {
414 10
415}
416
417#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419#[serde(rename_all = "camelCase")]
420pub struct SearchRequest {
421 pub query: String,
422 #[serde(default = "default_search_limit")]
423 pub limit: usize,
424 #[serde(default)]
425 pub lang: Option<String>,
426 #[serde(default)]
427 pub country: Option<String>,
428 #[serde(default)]
430 pub tbs: Option<String>,
431 #[serde(default)]
433 pub timeout: Option<u64>,
434 #[serde(default)]
435 pub scrape_options: Option<ScrapeOptions>,
436}
437
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
440#[serde(rename_all = "camelCase")]
441pub struct ExtractRequest {
442 pub urls: Vec<String>,
443 #[serde(default)]
444 pub prompt: Option<String>,
445 #[serde(default)]
446 pub schema: Option<Value>,
447 #[serde(default)]
448 pub enable_web_search: bool,
449 #[serde(default)]
450 pub ignore_invalid_urls: bool,
451 #[serde(default)]
452 pub scrape_options: Option<ScrapeOptions>,
453}
454
455#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct PagesiftMeta {
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub content_hash: Option<String>,
462 #[serde(skip_serializing_if = "Option::is_none")]
463 pub raw_hash: Option<String>,
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub text_hash: Option<String>,
466 #[serde(skip_serializing_if = "Vec::is_empty", default)]
467 pub redirect_chain: Vec<String>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub etag: Option<String>,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub last_modified: Option<String>,
472 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
473 pub not_modified: bool,
474 #[serde(skip_serializing_if = "Option::is_none")]
476 pub engine: Option<String>,
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub browser_engine: Option<String>,
480 #[serde(skip_serializing_if = "Vec::is_empty", default)]
481 pub warnings: Vec<String>,
482 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
484 pub timings: BTreeMap<String, f64>,
485 #[serde(skip_serializing_if = "Option::is_none")]
487 pub duplicate_of: Option<String>,
488 #[serde(skip_serializing_if = "Option::is_none")]
490 pub relevance: Option<f32>,
491 #[serde(skip_serializing_if = "Option::is_none")]
493 pub query_filter: Option<QueryFilterStats>,
494}
495
496#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
497#[serde(rename_all = "camelCase")]
498pub struct QueryFilterStats {
499 pub kept_blocks: usize,
500 pub total_blocks: usize,
501 pub tokens_before: usize,
502 pub tokens_after: usize,
503}
504
505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509pub struct ChangeTracking {
510 pub previous_scrape_at: Option<String>,
512 pub change_status: String,
514 pub visibility: String,
516 #[serde(skip_serializing_if = "Option::is_none")]
518 pub diff: Option<Value>,
519 #[serde(skip_serializing_if = "Option::is_none")]
521 pub json: Option<Value>,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 pub pagesift: Option<Value>,
525}
526
527#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct Metadata {
530 #[serde(skip_serializing_if = "Option::is_none")]
531 pub title: Option<String>,
532 #[serde(skip_serializing_if = "Option::is_none")]
533 pub description: Option<String>,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 pub language: Option<String>,
536 #[serde(skip_serializing_if = "Option::is_none")]
537 pub keywords: Option<String>,
538 #[serde(skip_serializing_if = "Option::is_none")]
539 pub robots: Option<String>,
540 #[serde(skip_serializing_if = "Option::is_none")]
541 pub favicon: Option<String>,
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub published_time: Option<String>,
544 #[serde(skip_serializing_if = "Option::is_none")]
546 pub author: Option<String>,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub modified_time: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 pub url: Option<String>,
551 #[serde(rename = "sourceURL", skip_serializing_if = "Option::is_none")]
552 pub source_url: Option<String>,
553 pub status_code: u16,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub content_type: Option<String>,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 pub error: Option<String>,
558 pub proxy_used: String,
560 #[serde(skip_serializing_if = "Option::is_none")]
561 pub cache_state: Option<String>,
562 #[serde(skip_serializing_if = "Option::is_none")]
563 pub cached_at: Option<String>,
564 #[serde(flatten)]
566 pub extra: BTreeMap<String, Value>,
567 #[serde(skip_serializing_if = "Option::is_none")]
568 pub pagesift: Option<PagesiftMeta>,
569}
570
571#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
572#[serde(rename_all = "camelCase")]
573pub struct Document {
574 #[serde(skip_serializing_if = "Option::is_none")]
575 pub markdown: Option<String>,
576 #[serde(skip_serializing_if = "Option::is_none")]
577 pub html: Option<String>,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 pub raw_html: Option<String>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub links: Option<Vec<String>>,
582 #[serde(skip_serializing_if = "Option::is_none")]
583 pub images: Option<Vec<String>>,
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub screenshot: Option<String>,
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub json: Option<Value>,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub summary: Option<String>,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub actions: Option<Value>,
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub warning: Option<String>,
594 #[serde(skip_serializing_if = "Option::is_none")]
595 pub change_tracking: Option<ChangeTracking>,
596 #[serde(skip_serializing_if = "Option::is_none")]
598 pub chunks: Option<Vec<DocChunk>>,
599 pub metadata: Metadata,
600}
601
602#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct DocChunk {
606 pub text: String,
607 pub heading_path: Vec<String>,
609 pub start: usize,
611 pub end: usize,
612 pub tokens: usize,
614}
615
616#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
617pub struct MapLink {
618 pub url: String,
619 #[serde(skip_serializing_if = "Option::is_none")]
620 pub title: Option<String>,
621 #[serde(skip_serializing_if = "Option::is_none")]
622 pub description: Option<String>,
623}
624
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
626pub struct ScrapeResponse {
627 pub success: bool,
628 pub data: Document,
629 #[serde(skip_serializing_if = "Option::is_none")]
630 pub warning: Option<String>,
631}
632
633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
634#[serde(rename_all = "camelCase")]
635pub struct JobStarted {
636 pub success: bool,
637 pub id: String,
638 pub url: String,
639 #[serde(rename = "invalidURLs", skip_serializing_if = "Option::is_none")]
640 pub invalid_urls: Option<Vec<String>>,
641}
642
643#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
644#[serde(rename_all = "camelCase")]
645pub struct JobStatus {
646 pub success: bool,
647 pub status: String,
649 pub completed: usize,
650 pub total: usize,
651 pub credits_used: usize,
652 pub expires_at: String,
653 #[serde(skip_serializing_if = "Option::is_none")]
654 pub next: Option<String>,
655 pub data: Vec<Document>,
656 #[serde(skip_serializing_if = "Option::is_none")]
658 pub pagesift: Option<CrawlSummary>,
659}
660
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
662pub struct JobError {
663 pub id: String,
664 pub timestamp: String,
665 pub url: String,
666 pub code: String,
667 pub error: String,
668}
669
670#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
671#[serde(rename_all = "camelCase")]
672pub struct JobErrors {
673 pub errors: Vec<JobError>,
674 pub robots_blocked: Vec<String>,
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678pub struct MapResponse {
679 pub success: bool,
680 pub links: Vec<MapLink>,
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use serde_json::json;
687
688 #[test]
689 fn formats_accept_strings_and_objects() {
690 let r: ScrapeRequest = serde_json::from_value(json!({
691 "url": "https://example.com",
692 "formats": ["markdown", {"type": "screenshot", "fullPage": true}],
693 }))
694 .unwrap();
695 assert_eq!(r.options.formats[0], Format::new("markdown"));
696 assert_eq!(r.options.formats[1].kind, "screenshot");
697 assert_eq!(r.options.formats[1].options["fullPage"], json!(true));
698 }
699
700 #[test]
701 fn defaults_follow_firecrawl_and_unknown_fields_are_ignored() {
702 let r: ScrapeRequest = serde_json::from_value(json!({
703 "url": "https://example.com",
704 "origin": "python-sdk",
705 "integration": null,
706 "zeroDataRetention": false,
707 }))
708 .unwrap();
709 let o = r.options;
710 assert_eq!(o.formats, vec![Format::new("markdown")]);
711 assert!(o.only_main_content && o.block_ads && o.store_in_cache && o.remove_base64_images);
712 assert_eq!(o.wait_for, 0);
713 assert!(!o.ignore_robots_txt);
714 }
715
716 #[test]
717 fn crawl_and_map_defaults() {
718 let c: CrawlRequest =
719 serde_json::from_value(json!({"url": "https://example.com"})).unwrap();
720 assert_eq!((c.limit, c.sitemap.as_str()), (10_000, "include"));
721 assert!(c.scrape_options.only_main_content);
722 let m: MapRequest = serde_json::from_value(json!({"url": "https://example.com"})).unwrap();
723 assert_eq!(m.limit, 5_000);
724 assert!(m.include_subdomains && m.ignore_query_parameters);
725 }
726
727 #[test]
728 fn query_filter_accepts_string_or_object() {
729 let r: ScrapeRequest = serde_json::from_value(json!({
730 "url": "https://example.com", "queryFilter": "refund policy",
731 }))
732 .unwrap();
733 let f = r.options.query_filter.unwrap();
734 assert_eq!(
735 (
736 f.query.as_str(),
737 f.threshold,
738 f.relative,
739 f.top_k,
740 f.headings
741 ),
742 ("refund policy", None, Some(0.5), None, true)
743 );
744 let r: ScrapeRequest = serde_json::from_value(json!({
745 "url": "https://example.com",
746 "queryFilter": {"query": "q", "threshold": 0.5, "topK": 3, "headings": false},
747 }))
748 .unwrap();
749 let f = r.options.query_filter.unwrap();
750 assert_eq!(
752 (f.threshold, f.relative, f.top_k, f.headings),
753 (Some(0.5), None, Some(3), false)
754 );
755 let r: ScrapeRequest = serde_json::from_value(json!({
756 "url": "https://example.com",
757 "queryFilter": {"query": "q", "maxTokens": 800},
758 }))
759 .unwrap();
760 let f = r.options.query_filter.unwrap();
761 assert_eq!((f.relative, f.max_tokens), (Some(0.5), Some(800)));
762 assert!(ScrapeOptions::default().query_filter.is_none());
763 }
764
765 #[test]
766 fn crawl_query_and_adaptive_options() {
767 let c: CrawlRequest =
768 serde_json::from_value(json!({"url": "https://example.com"})).unwrap();
769 assert_eq!((c.query, c.adaptive), (None, None));
770 let c: CrawlRequest = serde_json::from_value(json!({
771 "url": "https://example.com", "query": ["refund", "policy"], "adaptive": true,
772 }))
773 .unwrap();
774 assert_eq!(c.query.as_deref(), Some("refund policy"));
775 assert_eq!(c.adaptive, Some(AdaptiveOptions::default()));
776 let c: CrawlRequest = serde_json::from_value(json!({
777 "url": "https://example.com", "query": " ", "adaptive": {"patience": 2},
778 }))
779 .unwrap();
780 assert_eq!(c.query, None, "blank query is no query");
781 assert_eq!(c.adaptive.unwrap().patience, 2);
782 let c: CrawlRequest = serde_json::from_value(json!({
783 "url": "https://example.com", "query": null, "adaptive": false,
784 }))
785 .unwrap();
786 assert_eq!((c.query, c.adaptive), (None, None));
787 }
788
789 #[test]
790 fn change_tracking_serializes_firecrawl_fields() {
791 let doc = Document {
792 change_tracking: Some(ChangeTracking {
793 previous_scrape_at: None,
794 change_status: "new".into(),
795 visibility: "visible".into(),
796 diff: None,
797 json: None,
798 pagesift: None,
799 }),
800 ..Default::default()
801 };
802 let v = serde_json::to_value(&doc).unwrap();
803 assert_eq!(
804 v["changeTracking"],
805 json!({"previousScrapeAt": null, "changeStatus": "new", "visibility": "visible"})
806 );
807 assert!(
808 serde_json::to_value(Document::default())
809 .unwrap()
810 .get("changeTracking")
811 .is_none()
812 );
813 }
814
815 #[test]
816 fn metadata_serializes_firecrawl_names_and_extension() {
817 let mut meta = Metadata {
818 source_url: Some("https://example.com/a".into()),
819 status_code: 200,
820 proxy_used: "basic".into(),
821 pagesift: Some(PagesiftMeta {
822 content_hash: Some("ab".into()),
823 ..Default::default()
824 }),
825 ..Default::default()
826 };
827 meta.extra
828 .insert("ogImage".into(), json!("https://example.com/i.png"));
829 let v = serde_json::to_value(&meta).unwrap();
830 assert_eq!(v["sourceURL"], "https://example.com/a");
831 assert_eq!(v["statusCode"], 200);
832 assert_eq!(v["proxyUsed"], "basic");
833 assert_eq!(v["ogImage"], "https://example.com/i.png");
834 assert_eq!(v["pagesift"]["contentHash"], "ab");
835 assert!(v.get("title").is_none());
836 }
837}