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)]
124 pub discover_scripts: bool,
125 #[serde(default)]
126 pub if_none_match: Option<String>,
127 #[serde(default)]
128 pub if_modified_since: Option<String>,
129 #[serde(default)]
131 pub ignore_robots_txt: bool,
132 #[serde(default)]
133 pub store_snapshot: bool,
134 #[serde(default)]
136 pub query_filter: Option<QueryFilter>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub chunking: Option<Value>,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct QueryFilter {
154 pub query: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub threshold: Option<f32>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub relative: Option<f32>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub top_k: Option<usize>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub max_tokens: Option<usize>,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub min_tokens: Option<usize>,
170 pub headings: bool,
172}
173
174impl<'de> Deserialize<'de> for QueryFilter {
175 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
176 #[derive(Deserialize)]
177 #[serde(rename_all = "camelCase")]
178 struct Full {
179 query: String,
180 #[serde(default)]
181 threshold: Option<f32>,
182 #[serde(default)]
183 relative: Option<f32>,
184 #[serde(default)]
185 top_k: Option<usize>,
186 #[serde(default)]
187 max_tokens: Option<usize>,
188 #[serde(default)]
189 min_tokens: Option<usize>,
190 #[serde(default = "yes")]
191 headings: bool,
192 }
193 #[derive(Deserialize)]
194 #[serde(untagged)]
195 enum Repr {
196 Query(String),
197 Full(Full),
198 }
199 Ok(match Repr::deserialize(d)? {
200 Repr::Query(query) => QueryFilter {
201 query,
202 threshold: None,
203 relative: Some(pagesift_extract_defaults::RELATIVE),
204 top_k: None,
205 max_tokens: None,
206 min_tokens: Some(pagesift_extract_defaults::MIN_TOKENS),
207 headings: true,
208 },
209 Repr::Full(f) => QueryFilter {
210 query: f.query,
211 relative: f.relative.or(if f.threshold.is_none() {
212 Some(pagesift_extract_defaults::RELATIVE)
213 } else {
214 None
215 }),
216 threshold: f.threshold,
217 top_k: f.top_k,
218 max_tokens: f.max_tokens,
219 min_tokens: f.min_tokens.or(if f.threshold.is_none() {
220 Some(pagesift_extract_defaults::MIN_TOKENS)
221 } else {
222 None
223 }),
224 headings: f.headings,
225 },
226 })
227 }
228}
229
230mod pagesift_extract_defaults {
232 pub const RELATIVE: f32 = 0.5;
233 pub const MIN_TOKENS: usize = 1000;
234}
235
236impl Default for ScrapeOptions {
237 fn default() -> Self {
238 serde_json::from_value(Value::Object(Map::new())).expect("defaults deserialize")
239 }
240}
241
242impl ScrapeOptions {
243 pub fn wants(&self, kind: &str) -> bool {
244 self.formats.iter().any(|f| f.kind == kind)
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct ScrapeRequest {
250 pub url: String,
251 #[serde(flatten)]
252 pub options: ScrapeOptions,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct BatchScrapeRequest {
258 pub urls: Vec<String>,
259 #[serde(default = "yes")]
260 pub ignore_invalid_urls: bool,
261 #[serde(default)]
262 pub max_concurrency: Option<usize>,
263 #[serde(default)]
264 pub webhook: Option<Value>,
265 #[serde(flatten)]
266 pub options: ScrapeOptions,
267}
268
269fn default_crawl_limit() -> usize {
270 10_000
271}
272
273fn default_sitemap() -> String {
274 "include".into()
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct CrawlRequest {
280 pub url: String,
281 #[serde(default)]
282 pub include_paths: Vec<String>,
283 #[serde(default)]
284 pub exclude_paths: Vec<String>,
285 #[serde(default)]
286 pub max_discovery_depth: Option<usize>,
287 #[serde(default = "default_crawl_limit")]
288 pub limit: usize,
289 #[serde(default)]
290 pub crawl_entire_domain: bool,
291 #[serde(default)]
292 pub allow_external_links: bool,
293 #[serde(default)]
294 pub allow_subdomains: bool,
295 #[serde(default)]
296 pub ignore_robots_txt: bool,
297 #[serde(default = "default_sitemap")]
298 pub sitemap: String,
299 #[serde(default)]
300 pub ignore_query_parameters: bool,
301 #[serde(default)]
303 pub delay: Option<f64>,
304 #[serde(default)]
305 pub max_concurrency: Option<usize>,
306 #[serde(default)]
307 pub scrape_options: ScrapeOptions,
308 #[serde(default)]
309 pub webhook: Option<Value>,
310
311 #[serde(default, deserialize_with = "query_or_keywords")]
315 pub query: Option<String>,
316 #[serde(default, deserialize_with = "adaptive_options")]
318 pub adaptive: Option<AdaptiveOptions>,
319}
320
321fn query_or_keywords<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
322 #[derive(Deserialize)]
323 #[serde(untagged)]
324 enum Repr {
325 Query(String),
326 Keywords(Vec<String>),
327 }
328 Ok(match Option::<Repr>::deserialize(d)? {
329 Some(Repr::Query(q)) => Some(q),
330 Some(Repr::Keywords(k)) => Some(k.join(" ")),
331 None => None,
332 }
333 .filter(|q| !q.trim().is_empty()))
334}
335
336fn adaptive_options<'de, D: Deserializer<'de>>(d: D) -> Result<Option<AdaptiveOptions>, D::Error> {
337 #[derive(Deserialize)]
338 #[serde(untagged)]
339 enum Repr {
340 Flag(bool),
341 Options(AdaptiveOptions),
342 }
343 Ok(match Option::<Repr>::deserialize(d)? {
344 Some(Repr::Flag(true)) => Some(AdaptiveOptions::default()),
345 Some(Repr::Flag(false)) | None => None,
346 Some(Repr::Options(o)) => Some(o),
347 })
348}
349
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase", default)]
358pub struct AdaptiveOptions {
359 pub coverage_threshold: f64,
360 pub evidence_per_term: usize,
361 pub patience: usize,
362 pub min_gain: f64,
363}
364
365impl Default for AdaptiveOptions {
366 fn default() -> Self {
367 Self {
368 coverage_threshold: 0.9,
369 evidence_per_term: 3,
370 patience: 5,
371 min_gain: 0.05,
372 }
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct CrawlSummary {
380 pub stop_reason: String,
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub coverage: Option<f64>,
385 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
387 pub term_evidence: BTreeMap<String, usize>,
388 pub pages: usize,
390}
391
392fn default_map_limit() -> usize {
393 5_000
394}
395
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
397#[serde(rename_all = "camelCase")]
398pub struct MapRequest {
399 pub url: String,
400 #[serde(default)]
401 pub search: Option<String>,
402 #[serde(default = "yes")]
403 pub include_subdomains: bool,
404 #[serde(default = "yes")]
405 pub ignore_query_parameters: bool,
406 #[serde(default = "default_sitemap")]
407 pub sitemap: String,
408 #[serde(default = "default_map_limit")]
409 pub limit: usize,
410 #[serde(default)]
411 pub ignore_robots_txt: bool,
412 #[serde(default)]
414 pub timeout: Option<u64>,
415}
416
417fn default_search_limit() -> usize {
418 10
419}
420
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct SearchRequest {
425 pub query: String,
426 #[serde(default = "default_search_limit")]
427 pub limit: usize,
428 #[serde(default)]
429 pub lang: Option<String>,
430 #[serde(default)]
431 pub country: Option<String>,
432 #[serde(default)]
434 pub tbs: Option<String>,
435 #[serde(default)]
437 pub timeout: Option<u64>,
438 #[serde(default)]
439 pub scrape_options: Option<ScrapeOptions>,
440}
441
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub struct ExtractRequest {
446 pub urls: Vec<String>,
447 #[serde(default)]
448 pub prompt: Option<String>,
449 #[serde(default)]
450 pub schema: Option<Value>,
451 #[serde(default)]
452 pub enable_web_search: bool,
453 #[serde(default)]
454 pub ignore_invalid_urls: bool,
455 #[serde(default)]
456 pub scrape_options: Option<ScrapeOptions>,
457}
458
459#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
461#[serde(rename_all = "camelCase")]
462pub struct PagesiftMeta {
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub content_hash: Option<String>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub raw_hash: Option<String>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub text_hash: Option<String>,
470 #[serde(skip_serializing_if = "Vec::is_empty", default)]
471 pub redirect_chain: Vec<String>,
472 #[serde(skip_serializing_if = "Option::is_none")]
473 pub etag: Option<String>,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub last_modified: Option<String>,
476 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
477 pub not_modified: bool,
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub engine: Option<String>,
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub browser_engine: Option<String>,
484 #[serde(skip_serializing_if = "Vec::is_empty", default)]
485 pub warnings: Vec<String>,
486 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
488 pub timings: BTreeMap<String, f64>,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub duplicate_of: Option<String>,
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub relevance: Option<f32>,
495 #[serde(skip_serializing_if = "Option::is_none")]
497 pub query_filter: Option<QueryFilterStats>,
498}
499
500#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct QueryFilterStats {
503 pub kept_blocks: usize,
504 pub total_blocks: usize,
505 pub tokens_before: usize,
506 pub tokens_after: usize,
507}
508
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512#[serde(rename_all = "camelCase")]
513pub struct ChangeTracking {
514 pub previous_scrape_at: Option<String>,
516 pub change_status: String,
518 pub visibility: String,
520 #[serde(skip_serializing_if = "Option::is_none")]
522 pub diff: Option<Value>,
523 #[serde(skip_serializing_if = "Option::is_none")]
525 pub json: Option<Value>,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub pagesift: Option<Value>,
529}
530
531#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub struct Metadata {
534 #[serde(skip_serializing_if = "Option::is_none")]
535 pub title: Option<String>,
536 #[serde(skip_serializing_if = "Option::is_none")]
537 pub description: Option<String>,
538 #[serde(skip_serializing_if = "Option::is_none")]
539 pub language: Option<String>,
540 #[serde(skip_serializing_if = "Option::is_none")]
541 pub keywords: Option<String>,
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub robots: Option<String>,
544 #[serde(skip_serializing_if = "Option::is_none")]
545 pub favicon: Option<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub published_time: Option<String>,
548 #[serde(skip_serializing_if = "Option::is_none")]
550 pub author: Option<String>,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 pub modified_time: Option<String>,
553 #[serde(skip_serializing_if = "Option::is_none")]
554 pub url: Option<String>,
555 #[serde(rename = "sourceURL", skip_serializing_if = "Option::is_none")]
556 pub source_url: Option<String>,
557 pub status_code: u16,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 pub content_type: Option<String>,
560 #[serde(skip_serializing_if = "Option::is_none")]
561 pub error: Option<String>,
562 pub proxy_used: String,
564 #[serde(skip_serializing_if = "Option::is_none")]
565 pub cache_state: Option<String>,
566 #[serde(skip_serializing_if = "Option::is_none")]
567 pub cached_at: Option<String>,
568 #[serde(flatten)]
570 pub extra: BTreeMap<String, Value>,
571 #[serde(skip_serializing_if = "Option::is_none")]
572 pub pagesift: Option<PagesiftMeta>,
573}
574
575#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
576#[serde(rename_all = "camelCase")]
577pub struct Document {
578 #[serde(skip_serializing_if = "Option::is_none")]
579 pub markdown: Option<String>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub html: Option<String>,
582 #[serde(skip_serializing_if = "Option::is_none")]
583 pub raw_html: Option<String>,
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub links: Option<Vec<String>>,
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub images: Option<Vec<String>>,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub screenshot: Option<String>,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub json: Option<Value>,
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub summary: Option<String>,
594 #[serde(skip_serializing_if = "Option::is_none")]
595 pub actions: Option<Value>,
596 #[serde(skip_serializing_if = "Option::is_none")]
597 pub warning: Option<String>,
598 #[serde(skip_serializing_if = "Option::is_none")]
599 pub change_tracking: Option<ChangeTracking>,
600 #[serde(skip_serializing_if = "Option::is_none")]
602 pub chunks: Option<Vec<DocChunk>>,
603 pub metadata: Metadata,
604}
605
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608#[serde(rename_all = "camelCase")]
609pub struct DocChunk {
610 pub text: String,
611 pub heading_path: Vec<String>,
613 pub start: usize,
615 pub end: usize,
616 pub tokens: usize,
618}
619
620#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
621pub struct MapLink {
622 pub url: String,
623 #[serde(skip_serializing_if = "Option::is_none")]
624 pub title: Option<String>,
625 #[serde(skip_serializing_if = "Option::is_none")]
626 pub description: Option<String>,
627}
628
629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
630pub struct ScrapeResponse {
631 pub success: bool,
632 pub data: Document,
633 #[serde(skip_serializing_if = "Option::is_none")]
634 pub warning: Option<String>,
635}
636
637#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
638#[serde(rename_all = "camelCase")]
639pub struct JobStarted {
640 pub success: bool,
641 pub id: String,
642 pub url: String,
643 #[serde(rename = "invalidURLs", skip_serializing_if = "Option::is_none")]
644 pub invalid_urls: Option<Vec<String>>,
645}
646
647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648#[serde(rename_all = "camelCase")]
649pub struct JobStatus {
650 pub success: bool,
651 pub status: String,
653 pub completed: usize,
654 pub total: usize,
655 pub credits_used: usize,
656 pub expires_at: String,
657 #[serde(skip_serializing_if = "Option::is_none")]
658 pub next: Option<String>,
659 pub data: Vec<Document>,
660 #[serde(skip_serializing_if = "Option::is_none")]
662 pub pagesift: Option<CrawlSummary>,
663}
664
665#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
666pub struct JobError {
667 pub id: String,
668 pub timestamp: String,
669 pub url: String,
670 pub code: String,
671 pub error: String,
672}
673
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[serde(rename_all = "camelCase")]
676pub struct JobErrors {
677 pub errors: Vec<JobError>,
678 pub robots_blocked: Vec<String>,
679}
680
681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct MapResponse {
683 pub success: bool,
684 pub links: Vec<MapLink>,
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use serde_json::json;
691
692 #[test]
693 fn formats_accept_strings_and_objects() {
694 let r: ScrapeRequest = serde_json::from_value(json!({
695 "url": "https://example.com",
696 "formats": ["markdown", {"type": "screenshot", "fullPage": true}],
697 }))
698 .unwrap();
699 assert_eq!(r.options.formats[0], Format::new("markdown"));
700 assert_eq!(r.options.formats[1].kind, "screenshot");
701 assert_eq!(r.options.formats[1].options["fullPage"], json!(true));
702 }
703
704 #[test]
705 fn defaults_follow_firecrawl_and_unknown_fields_are_ignored() {
706 let r: ScrapeRequest = serde_json::from_value(json!({
707 "url": "https://example.com",
708 "origin": "python-sdk",
709 "integration": null,
710 "zeroDataRetention": false,
711 }))
712 .unwrap();
713 let o = r.options;
714 assert_eq!(o.formats, vec![Format::new("markdown")]);
715 assert!(o.only_main_content && o.block_ads && o.store_in_cache && o.remove_base64_images);
716 assert_eq!(o.wait_for, 0);
717 assert!(!o.ignore_robots_txt);
718 }
719
720 #[test]
721 fn crawl_and_map_defaults() {
722 let c: CrawlRequest =
723 serde_json::from_value(json!({"url": "https://example.com"})).unwrap();
724 assert_eq!((c.limit, c.sitemap.as_str()), (10_000, "include"));
725 assert!(c.scrape_options.only_main_content);
726 let m: MapRequest = serde_json::from_value(json!({"url": "https://example.com"})).unwrap();
727 assert_eq!(m.limit, 5_000);
728 assert!(m.include_subdomains && m.ignore_query_parameters);
729 }
730
731 #[test]
732 fn query_filter_accepts_string_or_object() {
733 let r: ScrapeRequest = serde_json::from_value(json!({
734 "url": "https://example.com", "queryFilter": "refund policy",
735 }))
736 .unwrap();
737 let f = r.options.query_filter.unwrap();
738 assert_eq!(
739 (
740 f.query.as_str(),
741 f.threshold,
742 f.relative,
743 f.top_k,
744 f.headings
745 ),
746 ("refund policy", None, Some(0.5), None, true)
747 );
748 let r: ScrapeRequest = serde_json::from_value(json!({
749 "url": "https://example.com",
750 "queryFilter": {"query": "q", "threshold": 0.5, "topK": 3, "headings": false},
751 }))
752 .unwrap();
753 let f = r.options.query_filter.unwrap();
754 assert_eq!(
756 (f.threshold, f.relative, f.top_k, f.headings),
757 (Some(0.5), None, Some(3), false)
758 );
759 let r: ScrapeRequest = serde_json::from_value(json!({
760 "url": "https://example.com",
761 "queryFilter": {"query": "q", "maxTokens": 800},
762 }))
763 .unwrap();
764 let f = r.options.query_filter.unwrap();
765 assert_eq!((f.relative, f.max_tokens), (Some(0.5), Some(800)));
766 assert!(ScrapeOptions::default().query_filter.is_none());
767 }
768
769 #[test]
770 fn crawl_query_and_adaptive_options() {
771 let c: CrawlRequest =
772 serde_json::from_value(json!({"url": "https://example.com"})).unwrap();
773 assert_eq!((c.query, c.adaptive), (None, None));
774 let c: CrawlRequest = serde_json::from_value(json!({
775 "url": "https://example.com", "query": ["refund", "policy"], "adaptive": true,
776 }))
777 .unwrap();
778 assert_eq!(c.query.as_deref(), Some("refund policy"));
779 assert_eq!(c.adaptive, Some(AdaptiveOptions::default()));
780 let c: CrawlRequest = serde_json::from_value(json!({
781 "url": "https://example.com", "query": " ", "adaptive": {"patience": 2},
782 }))
783 .unwrap();
784 assert_eq!(c.query, None, "blank query is no query");
785 assert_eq!(c.adaptive.unwrap().patience, 2);
786 let c: CrawlRequest = serde_json::from_value(json!({
787 "url": "https://example.com", "query": null, "adaptive": false,
788 }))
789 .unwrap();
790 assert_eq!((c.query, c.adaptive), (None, None));
791 }
792
793 #[test]
794 fn change_tracking_serializes_firecrawl_fields() {
795 let doc = Document {
796 change_tracking: Some(ChangeTracking {
797 previous_scrape_at: None,
798 change_status: "new".into(),
799 visibility: "visible".into(),
800 diff: None,
801 json: None,
802 pagesift: None,
803 }),
804 ..Default::default()
805 };
806 let v = serde_json::to_value(&doc).unwrap();
807 assert_eq!(
808 v["changeTracking"],
809 json!({"previousScrapeAt": null, "changeStatus": "new", "visibility": "visible"})
810 );
811 assert!(
812 serde_json::to_value(Document::default())
813 .unwrap()
814 .get("changeTracking")
815 .is_none()
816 );
817 }
818
819 #[test]
820 fn metadata_serializes_firecrawl_names_and_extension() {
821 let mut meta = Metadata {
822 source_url: Some("https://example.com/a".into()),
823 status_code: 200,
824 proxy_used: "basic".into(),
825 pagesift: Some(PagesiftMeta {
826 content_hash: Some("ab".into()),
827 ..Default::default()
828 }),
829 ..Default::default()
830 };
831 meta.extra
832 .insert("ogImage".into(), json!("https://example.com/i.png"));
833 let v = serde_json::to_value(&meta).unwrap();
834 assert_eq!(v["sourceURL"], "https://example.com/a");
835 assert_eq!(v["statusCode"], 200);
836 assert_eq!(v["proxyUsed"], "basic");
837 assert_eq!(v["ogImage"], "https://example.com/i.png");
838 assert_eq!(v["pagesift"]["contentHash"], "ab");
839 assert!(v.get("title").is_none());
840 }
841}