1use axum::http::HeaderMap;
6use serde_json::{Value, json};
7
8use crate::error::ApiError;
9use crate::types::{Format, ScrapeOptions};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RespondWith {
14 Default,
16 Markdown,
17 Html,
18 Text,
19 Frontmatter,
21 MarkdownFrontmatter,
23 Screenshot {
25 full_page: bool,
26 },
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Retain {
32 All,
33 None,
34 Text,
36 Image,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub enum Chunking {
43 Headings(u8),
44 Structured(u8),
46}
47
48#[derive(Debug, Clone)]
50pub struct JinaParams {
51 pub respond_with: RespondWith,
52 pub max_tokens: Option<usize>,
53 pub token_budget: Option<usize>,
54 pub retain_images: Retain,
55 pub retain_links: Retain,
56 pub retain_media: Retain,
57 pub links_summary: Option<bool>,
59 pub images_summary: Option<bool>,
60 pub chunking: Option<Chunking>,
61 pub assert_status: Option<u16>,
63 pub notes: Vec<String>,
65}
66
67fn get<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
68 h.get(name)
69 .and_then(|v| v.to_str().ok())
70 .map(str::trim)
71 .filter(|v| !v.is_empty())
72}
73
74fn bad(header: &str, value: &str, allowed: &str) -> ApiError {
75 ApiError::invalid(format!(
76 "{header}: unsupported value {value:?} (allowed: {allowed})"
77 ))
78}
79
80fn truthy(v: Option<&str>) -> bool {
81 v.is_some_and(|v| !matches!(v.to_ascii_lowercase().as_str(), "false" | "0" | "no"))
82}
83
84fn uint(h: &HeaderMap, name: &str) -> Result<Option<u64>, ApiError> {
85 get(h, name)
86 .map(|v| {
87 v.parse::<u64>()
88 .map_err(|_| ApiError::invalid(format!("{name} must be a non-negative integer")))
89 })
90 .transpose()
91}
92
93fn preset(name: &str) -> Result<&'static [(&'static str, &'static str)], ApiError> {
95 Ok(match name {
96 "reader" => &[
97 ("x-respond-with", "frontmatter"),
100 ],
101 "index" => &[
102 ("x-retain-links", "text"),
103 ("x-retain-images", "alt"),
104 ("x-retain-media", "none"),
105 ("x-markdown-chunking", "s3"),
106 ],
107 "research" => &[
108 ("x-respond-with", "markdown+frontmatter"),
109 ("x-markdown-chunking", "h3"),
110 ],
111 "agent" => &[
112 ("x-respond-with", "frontmatter"),
113 ("x-markdown-chunking", "h3"),
114 ("x-retain-images", "alt"),
115 ],
116 "spider" => &[
117 ("x-respond-with", "markdown+frontmatter"),
118 ("x-markdown-chunking", "h3"),
119 ("x-with-links-summary", "all"),
120 ],
121 other => {
122 return Err(bad(
123 "x-preset",
124 other,
125 "reader, index, research, agent, spider",
126 ));
127 }
128 })
129}
130
131pub fn parse(headers: &HeaderMap) -> Result<(ScrapeOptions, JinaParams), ApiError> {
134 let mut h = HeaderMap::new();
136 let mut notes = Vec::new();
137 if let Some(p) = get(headers, "x-preset") {
138 let name = p.to_ascii_lowercase();
139 for (k, v) in preset(&name)? {
140 h.insert(*k, v.parse().expect("static header value"));
141 }
142 if name == "reader" {
143 notes.push(
144 "preset_partial: reader without retainMedia html, detachInvisibles and removeOverlay, which pagesift does not support"
145 .to_string(),
146 );
147 }
148 }
149 for (k, v) in headers {
150 h.insert(k.clone(), v.clone());
151 }
152 let h = &h;
153 let mut opts = ScrapeOptions::default();
154 for (name, to) in [
155 ("x-user-agent", "User-Agent"),
156 ("x-referer", "Referer"),
157 ("x-locale", "Accept-Language"),
158 ] {
159 if let Some(v) = get(h, name) {
160 opts.headers.insert(to.into(), v.to_string());
161 }
162 }
163
164 check_unsupported(h)?;
165 let respond_with = match get(h, "x-respond-with")
166 .or_else(|| get(h, "x-return-format")) .map(str::to_ascii_lowercase)
168 .as_deref()
169 {
170 None => RespondWith::Default,
171 Some("markdown") => RespondWith::Markdown,
172 Some("html") => RespondWith::Html,
173 Some("text") => RespondWith::Text,
174 Some("frontmatter") => RespondWith::Frontmatter,
175 Some("markdown+frontmatter") => RespondWith::MarkdownFrontmatter,
176 Some("screenshot") => RespondWith::Screenshot { full_page: false },
177 Some("pageshot") => RespondWith::Screenshot { full_page: true },
178 Some(v @ ("vlm" | "readerlm-v2" | "content")) => {
179 return Err(ApiError::unsupported(format!(
180 "x-respond-with: {v} is not supported (it needs a hosted model); use markdown, html, text, frontmatter, markdown+frontmatter, screenshot or pageshot"
181 )));
182 }
183 Some(v) => {
184 return Err(bad(
185 "x-respond-with",
186 v,
187 "markdown, html, text, frontmatter, markdown+frontmatter, screenshot, pageshot",
188 ));
189 }
190 };
191 match respond_with {
192 RespondWith::Markdown
194 | RespondWith::MarkdownFrontmatter
195 | RespondWith::Html
196 | RespondWith::Text => {
197 opts.only_main_content = false;
198 }
199 _ => {}
200 }
201 if respond_with == RespondWith::Html {
202 opts.formats = vec![Format::new("rawHtml")];
203 }
204 if let RespondWith::Screenshot { full_page } = respond_with {
205 opts.engine = Some("browser".into());
206 opts.actions = vec![json!({ "type": "screenshot", "fullPage": full_page })];
207 }
208
209 if let Some(e) = get(h, "x-engine") {
210 opts.engine = Some(
211 match e.to_ascii_lowercase().as_str() {
212 "browser" => "browser",
213 "curl" | "direct" => "http",
214 "auto" => "auto",
215 other => return Err(bad("x-engine", other, "browser, curl, direct, auto")),
216 }
217 .into(),
218 );
219 }
220 if let Some(sel) = get(h, "x-target-selector") {
221 opts.include_tags = vec![sel.to_string()];
222 opts.only_main_content = false;
223 }
224 if let Some(sel) = get(h, "x-remove-selector") {
225 opts.exclude_tags = vec![sel.to_string()];
226 }
227 if let Some(sel) = get(h, "x-wait-for-selector") {
228 opts.actions
229 .insert(0, json!({ "type": "wait", "selector": sel }));
230 opts.engine.get_or_insert_with(|| "browser".into());
231 }
232 let timeout = uint(h, "x-timeout")?;
233 if let Some(t) = timeout {
234 opts.timeout = Some(t.saturating_mul(1000));
235 }
236 if let Some(t) = get(h, "x-respond-timing") {
237 let t = t.to_ascii_lowercase();
238 if !crate::types::RESPOND_TIMINGS.contains(&t.as_str()) {
239 return Err(bad(
240 "x-respond-timing",
241 &t,
242 &crate::types::RESPOND_TIMINGS.join(", "),
243 ));
244 }
245 if t == "html" {
247 opts.engine.get_or_insert_with(|| "http".into());
248 } else {
249 opts.engine.get_or_insert_with(|| "browser".into());
250 }
251 opts.respond_timing = Some(t);
252 } else if timeout.is_some_and(|t| t >= JINA_NETWORK_IDLE_TIMEOUT_SECS) {
253 opts.respond_timing = Some("network-idle".into());
255 }
256 if truthy(get(h, "x-no-cache")) {
257 opts.max_age = Some(0);
258 }
259 if let Some(s) = uint(h, "x-cache-tolerance")? {
260 opts.max_age = Some(s.saturating_mul(1000));
261 }
262 if let Some(c) = get(h, "x-set-cookie") {
263 let pairs: Vec<&str> = c
265 .split(',')
266 .filter_map(|one| one.split(';').next())
267 .map(str::trim)
268 .filter(|kv| kv.contains('='))
269 .collect();
270 opts.headers.insert("Cookie".into(), pairs.join("; "));
271 opts.max_age = Some(0);
272 opts.store_in_cache = false;
273 }
274 if let Some(p) = get(h, "x-proxy-url").or_else(|| get(h, "x-proxy")) {
275 if p.contains("://") || p.contains('@') || p.contains(':') {
276 return Err(ApiError::invalid(format!(
277 "x-proxy-url: only egress names configured in pagesift.toml are accepted (got {p:?}); arbitrary proxy URLs are refused so requests cannot be routed into internal networks"
278 )));
279 }
280 opts.egress = Some(p.to_string());
281 }
282 if truthy(get(h, "x-with-generated-alt")) {
283 return Err(ApiError::unsupported(
284 "x-with-generated-alt is not supported: pagesift does not caption images with a vision model",
285 ));
286 }
287 if truthy(get(h, "x-detach-invisibles")) {
288 return Err(ApiError::unsupported(
289 "x-detach-invisibles is not supported",
290 ));
291 }
292 if truthy(get(h, "x-with-iframe")) || truthy(get(h, "x-with-shadow-dom")) {
293 opts.engine.get_or_insert_with(|| "browser".into());
295 }
296
297 let retain = |name: &str, allowed: &[(&str, Retain)]| -> Result<Retain, ApiError> {
298 match get(h, name).map(str::to_ascii_lowercase) {
299 None => Ok(Retain::All),
300 Some(v) => allowed
301 .iter()
302 .find(|(k, _)| *k == v)
303 .map(|(_, r)| *r)
304 .ok_or_else(|| {
305 let names: Vec<&str> = allowed.iter().map(|(k, _)| *k).collect();
306 bad(name, &v, &names.join(", "))
307 }),
308 }
309 };
310 let retain_images = retain(
311 "x-retain-images",
312 &[
313 ("all", Retain::All),
314 ("none", Retain::None),
315 ("alt", Retain::Text),
316 ],
317 )?;
318 if get(h, "x-retain-links").is_some_and(|v| v.eq_ignore_ascii_case("gpt-oss")) {
319 return Err(ApiError::unsupported(
320 "x-retain-links: gpt-oss citations are not supported; use all, none or text",
321 ));
322 }
323 let links_discarded = get(h, "x-md-link-style").is_some_and(|v| v == "discarded")
325 && get(h, "x-retain-links").is_none();
326 let retain_links = retain(
327 "x-retain-links",
328 &[
329 ("all", Retain::All),
330 ("none", Retain::None),
331 ("text", Retain::Text),
332 ],
333 )?;
334 if get(h, "x-retain-media").is_some_and(|v| v.eq_ignore_ascii_case("html")) {
335 return Err(ApiError::unsupported(
336 "x-retain-media: html is not supported (the original media element is not kept); use link, none, text or image",
337 ));
338 }
339 let retain_media = retain(
340 "x-retain-media",
341 &[
342 ("link", Retain::All),
343 ("none", Retain::None),
344 ("text", Retain::Text),
345 ("image", Retain::Image),
346 ],
347 )?;
348 let summary = |name: &str| match get(h, name).map(str::to_ascii_lowercase).as_deref() {
349 None | Some("false" | "0") => None,
350 Some("all") => Some(true),
351 Some(_) => Some(false),
352 };
353 let chunking = match get(h, "x-markdown-chunking")
354 .map(str::to_ascii_lowercase)
355 .as_deref()
356 {
357 None | Some("false") => None,
358 Some("true") => Some(Chunking::Headings(JINA_DEFAULT_HEADING_LEVEL)),
359 Some("structured") => Some(Chunking::Structured(JINA_DEFAULT_STRUCTURED_LEVEL)),
360 Some(v) => {
361 let level = |p: char| {
362 v.strip_prefix(p)
363 .and_then(|n| n.parse::<u8>().ok())
364 .filter(|n| (1..=5).contains(n)) };
366 match (level('h'), level('s')) {
367 (Some(n), _) => Some(Chunking::Headings(n)),
368 (_, Some(n)) => Some(Chunking::Structured(n)),
369 _ => {
370 return Err(bad(
371 "x-markdown-chunking",
372 v,
373 "true, h1…h5, structured, s1…s5",
374 ));
375 }
376 }
377 }
378 };
379 if let Some(c) = &chunking {
380 opts.chunking = Some(chunk_spec(c));
381 }
382 Ok((
383 opts,
384 JinaParams {
385 respond_with,
386 max_tokens: uint(h, "x-max-tokens")?.map(|n| n as usize),
387 token_budget: uint(h, "x-token-budget")?.map(|n| n as usize),
388 retain_images,
389 retain_links: if links_discarded {
390 Retain::Text
391 } else {
392 retain_links
393 },
394 retain_media,
395 links_summary: summary("x-with-links-summary"),
396 images_summary: summary("x-with-images-summary"),
397 chunking,
398 assert_status: uint(h, "x-assert-status-code")?.map(|n| n as u16),
399 notes,
400 },
401 ))
402}
403
404const MD_FIXED: &[(&str, &str)] = &[
406 ("x-md-heading-style", "atx"),
407 ("x-md-hr", "---"),
408 ("x-md-bullet-list-marker", "-"),
409 ("x-md-em-delimiter", "*"),
410 ("x-md-strong-delimiter", "**"),
411];
412
413fn check_unsupported(h: &HeaderMap) -> Result<(), ApiError> {
415 for (name, fixed) in MD_FIXED {
416 if let Some(v) = get(h, name)
417 && v != *fixed
418 {
419 return Err(ApiError::unsupported(format!(
420 "{name}: {v} is not supported; pagesift writes {fixed}"
421 )));
422 }
423 }
424 if let Some(v) = get(h, "x-md-link-style")
425 && !matches!(v, "inlined" | "discarded")
426 {
427 return Err(ApiError::unsupported(format!(
428 "x-md-link-style: {v} is not supported; use inlined or discarded"
429 )));
430 }
431 if get(h, "x-md-link-reference-style").is_some() {
432 return Err(ApiError::unsupported(
433 "x-md-link-reference-style is not supported: pagesift writes inline links",
434 ));
435 }
436 for (name, why) in [
437 ("x-no-gfm", "pagesift writes GitHub-flavored tables"),
438 ("x-keep-img-data-url", "data URLs are not kept in Markdown"),
439 ("x-remove-overlay", "overlays are not removed"),
440 ] {
441 if truthy(get(h, name)) {
442 return Err(ApiError::unsupported(format!(
443 "{name} is not supported: {why}"
444 )));
445 }
446 }
447 if get(h, "x-page").is_some() {
448 return Err(ApiError::unsupported(
449 "x-page is not supported: /r/ does not take file uploads; use /v2/parse",
450 ));
451 }
452 if let Some(v) = get(h, "x-base")
453 && !v.eq_ignore_ascii_case("final")
454 {
455 return Err(ApiError::unsupported(format!(
456 "x-base: {v} is not supported; relative URLs resolve against the final URL"
457 )));
458 }
459 if let Some(v) = get(h, "x-robots-txt")
461 && !matches!(v.to_ascii_lowercase().as_str(), "true" | "1" | "pagesift")
462 {
463 return Err(ApiError::unsupported(format!(
464 "x-robots-txt: {v} is not supported; robots.txt is always checked as pagesift"
465 )));
466 }
467 Ok(())
468}
469
470const JINA_NETWORK_IDLE_TIMEOUT_SECS: u64 = 20;
472const JINA_DEFAULT_HEADING_LEVEL: u8 = 3;
474const JINA_DEFAULT_STRUCTURED_LEVEL: u8 = 3;
476
477fn chunk_spec(c: &Chunking) -> Value {
480 match c {
481 Chunking::Headings(n) => json!({ "type": "headings", "maxLevel": n }),
482 Chunking::Structured(1) => json!({ "type": "headings", "maxLevel": 6 }), Chunking::Structured(2) => json!({ "type": "paragraphs" }),
484 Chunking::Structured(3) => json!({ "type": "regex", "patterns": ["\n"] }),
485 Chunking::Structured(4) => json!({ "type": "sentences" }),
486 Chunking::Structured(_) => {
487 json!({ "type": "regex", "patterns": ["[,;:,;:、]\\s*", "\n"] })
488 }
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 fn hdrs(pairs: &[(&str, &str)]) -> HeaderMap {
497 let mut h = HeaderMap::new();
498 for (k, v) in pairs {
499 h.insert(
500 axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
501 v.parse().unwrap(),
502 );
503 }
504 h
505 }
506
507 #[test]
508 fn maps_selectors_engine_timeout_cache_and_presets() {
509 let (o, p) = parse(&hdrs(&[
510 ("x-engine", "curl"),
511 ("x-target-selector", "article"),
512 ("x-remove-selector", ".ads"),
513 ("x-timeout", "25"),
514 ("x-no-cache", "true"),
515 ("x-preset", "index"),
516 ("x-retain-links", "all"),
517 ]))
518 .unwrap();
519 assert_eq!(o.engine.as_deref(), Some("http"));
520 assert_eq!(o.include_tags, ["article"]);
521 assert_eq!(o.exclude_tags, [".ads"]);
522 assert_eq!(o.timeout, Some(25_000));
523 assert_eq!(o.respond_timing.as_deref(), Some("network-idle"));
524 assert_eq!(o.max_age, Some(0));
525 assert_eq!(p.retain_links, Retain::All);
527 assert_eq!(p.retain_images, Retain::Text);
528 assert_eq!(p.chunking, Some(Chunking::Structured(3)));
529 }
530
531 #[test]
532 fn refuses_what_it_cannot_do() {
533 for h in [
534 hdrs(&[("x-with-generated-alt", "true")]),
535 hdrs(&[("x-proxy-url", "http://10.0.0.1:3128")]),
536 hdrs(&[("x-respond-with", "vlm")]),
537 hdrs(&[("x-retain-media", "html")]),
538 hdrs(&[("x-markdown-chunking", "h9")]),
539 hdrs(&[("x-respond-timing", "eventually")]),
540 ] {
541 assert!(parse(&h).is_err(), "{h:?}");
542 }
543 let (o, _) = parse(&hdrs(&[("x-proxy-url", "office")])).unwrap();
544 assert_eq!(o.egress.as_deref(), Some("office"));
545 }
546}