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    #[serde(default)]
122    pub if_none_match: Option<String>,
123    #[serde(default)]
124    pub if_modified_since: Option<String>,
125    /// 单页抓取可以由调用方关闭 robots 检查(PRD F-2.4)。
126    #[serde(default)]
127    pub ignore_robots_txt: bool,
128    #[serde(default)]
129    pub store_snapshot: bool,
130    /// 只保留正文里和查询相关的段落(BM25),见 [`QueryFilter`]。不给时输出完整正文。
131    #[serde(default)]
132    pub query_filter: Option<QueryFilter>,
133    /// pagesift 扩展:把(过滤后的)正文 Markdown 分块,结果在 `chunks`。例如
134    /// `{"type": "recursive", "size": 400}`(RAG 推荐)、`{"type": "headings", "maxLevel": 2}`、
135    /// `{"type": "sliding_window", "window": 200, "step": 100}`;策略见 pagesift-extract 的 `ChunkStrategy`。
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub chunking: Option<Value>,
138}
139
140/// pagesift 扩展 `queryFilter`:按查询过滤正文 Markdown,省 token。
141/// 可以直接写查询字符串,也可以写
142/// `{"query": "...", "relative": 0.5, "minTokens": 1000, "threshold": 1.0, "topK": 5, "maxTokens": 800, "headings": true}`。
143/// 正文先切成约 100 token 的单位(不跨小节)用 BM25 打分;默认保留分数不低于最高分一半的单位,
144/// 留下的不到 1000 token 时按分数补足。
145/// 只给 `threshold`(Crawl4AI 的 `bm25_threshold` 写法)时只按绝对阈值,不再加相对阈值和保底。
146/// `summary`、`json` 和 `changeTracking` 都基于过滤后的正文。
147#[derive(Debug, Clone, PartialEq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct QueryFilter {
150    pub query: String,
151    /// 绝对阈值:BM25 分数不低于它的单位才保留。
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub threshold: Option<f32>,
154    /// 相对阈值:分数不低于最高分的这个比例才保留。不给 `threshold` 时默认 0.5。
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub relative: Option<f32>,
157    /// 最多保留分数最高的几个单位。
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub top_k: Option<usize>,
160    /// 保留内容的 token 上限(按分数从高到低取)。
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub max_tokens: Option<usize>,
163    /// 保底 token 数:阈值留下的不到这么多时按分数补足。不给 `threshold` 时默认 1000。
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub min_tokens: Option<usize>,
166    /// 在保留的内容前补上它所在的各级标题,默认开。
167    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
226/// queryFilter 的默认相对阈值和保底 token 数(和 pagesift-extract 的 `DEFAULT_RELATIVE`、`DEFAULT_MIN_TOKENS` 一致;API 层不依赖提取库)。
227mod 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    /// 秒。
298    #[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    // ---- pagesift 扩展 ----
308    /// 相关性优先爬取:给了查询(字符串,或关键词数组)时,按链接和查询的相关性决定先爬哪页,
309    /// 不再按广度优先。不给时行为和 Firecrawl 一样。
310    #[serde(default, deserialize_with = "query_or_keywords")]
311    pub query: Option<String>,
312    /// 自适应爬取:信息够了就提前停止,需要同时给 `query`。写 `true` 用默认参数。
313    #[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/// 自适应爬取的停止条件(pagesift 扩展 `adaptive`)。
347///
348/// 每个查询词的"证据"是包含它的、互不相同的相关段落数;覆盖度是各词
349/// `min(1, 证据 / evidencePerTerm)` 的平均值。覆盖度达到 `coverageThreshold` 时停止
350/// (`coverage_reached`);或者连续 `patience` 页带来的新相关内容都少于已收集的 `minGain`
351/// 时停止(`saturated`)。
352#[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/// 带查询的爬取结束时的说明,放在任务状态的 `pagesift` 字段里。
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase")]
375pub struct CrawlSummary {
376    /// `coverage_reached`、`saturated`(自适应提前停止),`limit_reached`、`exhausted`(没有更多页面)、`cancelled`。
377    pub stop_reason: String,
378    /// 自适应爬取结束时的覆盖度,0 到 1。
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub coverage: Option<f64>,
381    /// 每个查询词(词干)收集到的证据段落数。
382    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
383    pub term_evidence: BTreeMap<String, usize>,
384    /// 实际抓取的页面数。
385    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    /// 毫秒。
409    #[serde(default)]
410    pub timeout: Option<u64>,
411}
412
413fn default_search_limit() -> usize {
414    10
415}
416
417/// `/v2/search`。只有 `scrapeOptions` 里给了 formats 时才抓取搜索结果页面(和 Firecrawl 一致)。
418#[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    /// 时间范围:`qdr:d`、`qdr:w`、`qdr:m`、`qdr:y`。
429    #[serde(default)]
430    pub tbs: Option<String>,
431    /// 毫秒。
432    #[serde(default)]
433    pub timeout: Option<u64>,
434    #[serde(default)]
435    pub scrape_options: Option<ScrapeOptions>,
436}
437
438/// `/v2/extract`:从一组页面里按 schema 或提示提取结构化数据。URL 以 `/*` 结尾时先 map 整站。
439#[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/// pagesift 在 `metadata.pagesift` 里附加的字段。
456#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct PagesiftMeta {
459    #[serde(skip_serializing_if = "Option::is_none")]
460    /// 正文 Markdown、原始 HTML、规范化纯文本的 BLAKE3 哈希(小写十六进制)。
461    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    /// `http` 或 `browser`:这一页实际用哪种方式抓到的。
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub engine: Option<String>,
477    /// 浏览器渲染时实际用的内核:`lightpanda`、`moli` 或 `chrome`。
478    #[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    /// 各阶段耗时,毫秒。
483    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
484    pub timings: BTreeMap<String, f64>,
485    /// 整站爬取里,这一页的正文和之前抓到的某一页完全相同(同一内容的另一个 URL):那一页的 URL。
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub duplicate_of: Option<String>,
488    /// 带查询的爬取里,这一页和查询的相关度(0 到 1)。
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub relevance: Option<f32>,
491    /// `queryFilter` 的效果:保留了几段、原来几段,过滤前后的 token 估算。
492    #[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/// Firecrawl 的 `changeTracking` 结果(格式 `{"type": "changeTracking", "modes": [...], "tag": ...}`)。
506/// 和上一次抓取同一 URL(同一 `tag`)的正文 Markdown 比较。
507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509pub struct ChangeTracking {
510    /// 上一次抓取的时间;第一次抓取时是 null。
511    pub previous_scrape_at: Option<String>,
512    /// `new`、`same`、`changed`、`removed`。
513    pub change_status: String,
514    /// `visible`(通过链接或 sitemap 发现)或 `hidden`。pagesift 不从历史里发现页面,总是 `visible`。
515    pub visibility: String,
516    /// `git-diff` 模式:`{"text": 统一格式文本, "json": parse-diff 结构}`,只在 `changed` 时给。
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub diff: Option<Value>,
519    /// `json` 模式:按 schema 提取前后两版,变了的字段给出 `{"previous", "current"}`。
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub json: Option<Value>,
522    /// pagesift 扩展:块级 diff 的结论,区分噪声变化(日期、计数等)和实质变化。
523    #[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    /// pagesift 扩展:作者,依次取 JSON-LD 和 meta。
545    #[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    /// pagesift 只有 basic。
559    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    /// og:*、twitter:*、dc.* 这类,键名按 Firecrawl 的 camelCase 风格(如 `ogImage`)。
565    #[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    /// 请求了 `chunking` 时,正文 Markdown 的分块。
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub chunks: Option<Vec<DocChunk>>,
599    pub metadata: Metadata,
600}
601
602/// 正文 Markdown 的一块。
603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct DocChunk {
606    pub text: String,
607    /// 从最外层到最内层的标题。
608    pub heading_path: Vec<String>,
609    /// 在 `markdown` 里的 UTF-8 字节区间。
610    pub start: usize,
611    pub end: usize,
612    /// token 估算:拉丁文按空白分词,中日韩每个字算一个。
613    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    /// `scraping`、`completed`、`failed`、`cancelled`。
648    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    /// pagesift 扩展:带查询的爬取结束后的说明(停止原因、覆盖度)。
657    #[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        // 只给绝对阈值(Crawl4AI 写法)时不加相对阈值
751        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}