Skip to main content

pagesift_api/
types.rs

1//! Firecrawl v2 的请求和响应类型。字段名和默认值以 Firecrawl 源码
2//! `apps/api/src/controllers/v2/types.ts` 为准;pagesift 自己的扩展字段单独标注。
3//! 请求里的未知字段一律忽略(Firecrawl 会拒绝,我们宽松一些,方便旧客户端)。
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Deserializer, Serialize};
8use serde_json::{Map, Value};
9
10/// `formats` 的一项:Firecrawl 同时接受 `"markdown"` 和 `{"type": "markdown", ...}` 两种写法。
11#[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/// 单页抓取选项(Firecrawl 的 `scrapeOptions`)。
67#[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    /// 毫秒。
81    #[serde(default)]
82    pub timeout: Option<u64>,
83    /// 毫秒。
84    #[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    /// `basic`、`auto` 放行,其余取值返回 UNSUPPORTED_OPTION。
99    #[serde(default)]
100    pub proxy: Option<String>,
101    /// pagesift 扩展:走服务端配置的哪个命名出口(`[[egress]]`)。只用于 HTTP 抓取;
102    /// 指定了出口时不升级到浏览器(浏览器不走这个出口,出口地址会不一致)。
103    #[serde(default)]
104    pub egress: Option<String>,
105    /// Firecrawl 默认跳过 TLS 校验;pagesift 始终校验证书,这个字段只接受、不生效。
106    #[serde(default)]
107    pub skip_tls_verification: Option<bool>,
108    /// 毫秒。
109    #[serde(default)]
110    pub max_age: Option<u64>,
111    /// 毫秒。
112    #[serde(default)]
113    pub min_age: Option<u64>,
114    #[serde(default = "yes")]
115    pub store_in_cache: bool,
116
117    // ---- pagesift 扩展 ----
118    /// `http`、`browser`、`auto`(默认)。
119    #[serde(default)]
120    pub engine: Option<String>,
121    /// pagesift extension: bounded, stateless same-origin external JS discovery.
122    /// Requires engine=http and links; never silently runs for ordinary scrapes.
123    #[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    /// 单页抓取可以由调用方关闭 robots 检查(PRD F-2.4)。
130    #[serde(default)]
131    pub ignore_robots_txt: bool,
132    #[serde(default)]
133    pub store_snapshot: bool,
134    /// 只保留正文里和查询相关的段落(BM25),见 [`QueryFilter`]。不给时输出完整正文。
135    #[serde(default)]
136    pub query_filter: Option<QueryFilter>,
137    /// pagesift 扩展:把(过滤后的)正文 Markdown 分块,结果在 `chunks`。例如
138    /// `{"type": "recursive", "size": 400}`(RAG 推荐)、`{"type": "headings", "maxLevel": 2}`、
139    /// `{"type": "sliding_window", "window": 200, "step": 100}`;策略见 pagesift-extract 的 `ChunkStrategy`。
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub chunking: Option<Value>,
142}
143
144/// pagesift 扩展 `queryFilter`:按查询过滤正文 Markdown,省 token。
145/// 可以直接写查询字符串,也可以写
146/// `{"query": "...", "relative": 0.5, "minTokens": 1000, "threshold": 1.0, "topK": 5, "maxTokens": 800, "headings": true}`。
147/// 正文先切成约 100 token 的单位(不跨小节)用 BM25 打分;默认保留分数不低于最高分一半的单位,
148/// 留下的不到 1000 token 时按分数补足。
149/// 只给 `threshold`(Crawl4AI 的 `bm25_threshold` 写法)时只按绝对阈值,不再加相对阈值和保底。
150/// `summary`、`json` 和 `changeTracking` 都基于过滤后的正文。
151#[derive(Debug, Clone, PartialEq, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct QueryFilter {
154    pub query: String,
155    /// 绝对阈值:BM25 分数不低于它的单位才保留。
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub threshold: Option<f32>,
158    /// 相对阈值:分数不低于最高分的这个比例才保留。不给 `threshold` 时默认 0.5。
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub relative: Option<f32>,
161    /// 最多保留分数最高的几个单位。
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub top_k: Option<usize>,
164    /// 保留内容的 token 上限(按分数从高到低取)。
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub max_tokens: Option<usize>,
167    /// 保底 token 数:阈值留下的不到这么多时按分数补足。不给 `threshold` 时默认 1000。
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub min_tokens: Option<usize>,
170    /// 在保留的内容前补上它所在的各级标题,默认开。
171    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
230/// queryFilter 的默认相对阈值和保底 token 数(和 pagesift-extract 的 `DEFAULT_RELATIVE`、`DEFAULT_MIN_TOKENS` 一致;API 层不依赖提取库)。
231mod 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    /// 秒。
302    #[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    // ---- pagesift 扩展 ----
312    /// 相关性优先爬取:给了查询(字符串,或关键词数组)时,按链接和查询的相关性决定先爬哪页,
313    /// 不再按广度优先。不给时行为和 Firecrawl 一样。
314    #[serde(default, deserialize_with = "query_or_keywords")]
315    pub query: Option<String>,
316    /// 自适应爬取:信息够了就提前停止,需要同时给 `query`。写 `true` 用默认参数。
317    #[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/// 自适应爬取的停止条件(pagesift 扩展 `adaptive`)。
351///
352/// 每个查询词的"证据"是包含它的、互不相同的相关段落数;覆盖度是各词
353/// `min(1, 证据 / evidencePerTerm)` 的平均值。覆盖度达到 `coverageThreshold` 时停止
354/// (`coverage_reached`);或者连续 `patience` 页带来的新相关内容都少于已收集的 `minGain`
355/// 时停止(`saturated`)。
356#[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/// 带查询的爬取结束时的说明,放在任务状态的 `pagesift` 字段里。
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct CrawlSummary {
380    /// `coverage_reached`、`saturated`(自适应提前停止),`limit_reached`、`exhausted`(没有更多页面)、`cancelled`。
381    pub stop_reason: String,
382    /// 自适应爬取结束时的覆盖度,0 到 1。
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub coverage: Option<f64>,
385    /// 每个查询词(词干)收集到的证据段落数。
386    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
387    pub term_evidence: BTreeMap<String, usize>,
388    /// 实际抓取的页面数。
389    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    /// 毫秒。
413    #[serde(default)]
414    pub timeout: Option<u64>,
415}
416
417fn default_search_limit() -> usize {
418    10
419}
420
421/// `/v2/search`。只有 `scrapeOptions` 里给了 formats 时才抓取搜索结果页面(和 Firecrawl 一致)。
422#[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    /// 时间范围:`qdr:d`、`qdr:w`、`qdr:m`、`qdr:y`。
433    #[serde(default)]
434    pub tbs: Option<String>,
435    /// 毫秒。
436    #[serde(default)]
437    pub timeout: Option<u64>,
438    #[serde(default)]
439    pub scrape_options: Option<ScrapeOptions>,
440}
441
442/// `/v2/extract`:从一组页面里按 schema 或提示提取结构化数据。URL 以 `/*` 结尾时先 map 整站。
443#[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/// pagesift 在 `metadata.pagesift` 里附加的字段。
460#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
461#[serde(rename_all = "camelCase")]
462pub struct PagesiftMeta {
463    #[serde(skip_serializing_if = "Option::is_none")]
464    /// 正文 Markdown、原始 HTML、规范化纯文本的 BLAKE3 哈希(小写十六进制)。
465    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    /// `http` 或 `browser`:这一页实际用哪种方式抓到的。
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub engine: Option<String>,
481    /// 浏览器渲染时实际用的内核:`lightpanda`、`moli` 或 `chrome`。
482    #[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    /// 各阶段耗时,毫秒。
487    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
488    pub timings: BTreeMap<String, f64>,
489    /// 整站爬取里,这一页的正文和之前抓到的某一页完全相同(同一内容的另一个 URL):那一页的 URL。
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub duplicate_of: Option<String>,
492    /// 带查询的爬取里,这一页和查询的相关度(0 到 1)。
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub relevance: Option<f32>,
495    /// `queryFilter` 的效果:保留了几段、原来几段,过滤前后的 token 估算。
496    #[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/// Firecrawl 的 `changeTracking` 结果(格式 `{"type": "changeTracking", "modes": [...], "tag": ...}`)。
510/// 和上一次抓取同一 URL(同一 `tag`)的正文 Markdown 比较。
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512#[serde(rename_all = "camelCase")]
513pub struct ChangeTracking {
514    /// 上一次抓取的时间;第一次抓取时是 null。
515    pub previous_scrape_at: Option<String>,
516    /// `new`、`same`、`changed`、`removed`。
517    pub change_status: String,
518    /// `visible`(通过链接或 sitemap 发现)或 `hidden`。pagesift 不从历史里发现页面,总是 `visible`。
519    pub visibility: String,
520    /// `git-diff` 模式:`{"text": 统一格式文本, "json": parse-diff 结构}`,只在 `changed` 时给。
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub diff: Option<Value>,
523    /// `json` 模式:按 schema 提取前后两版,变了的字段给出 `{"previous", "current"}`。
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub json: Option<Value>,
526    /// pagesift 扩展:块级 diff 的结论,区分噪声变化(日期、计数等)和实质变化。
527    #[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    /// pagesift 扩展:作者,依次取 JSON-LD 和 meta。
549    #[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    /// pagesift 只有 basic。
563    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    /// og:*、twitter:*、dc.* 这类,键名按 Firecrawl 的 camelCase 风格(如 `ogImage`)。
569    #[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    /// 请求了 `chunking` 时,正文 Markdown 的分块。
601    #[serde(skip_serializing_if = "Option::is_none")]
602    pub chunks: Option<Vec<DocChunk>>,
603    pub metadata: Metadata,
604}
605
606/// 正文 Markdown 的一块。
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608#[serde(rename_all = "camelCase")]
609pub struct DocChunk {
610    pub text: String,
611    /// 从最外层到最内层的标题。
612    pub heading_path: Vec<String>,
613    /// 在 `markdown` 里的 UTF-8 字节区间。
614    pub start: usize,
615    pub end: usize,
616    /// token 估算:拉丁文按空白分词,中日韩每个字算一个。
617    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    /// `scraping`、`completed`、`failed`、`cancelled`。
652    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    /// pagesift 扩展:带查询的爬取结束后的说明(停止原因、覆盖度)。
661    #[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        // 只给绝对阈值(Crawl4AI 写法)时不加相对阈值
755        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}