Skip to main content

pagesift_api/jina/
headers.rs

1//! Jina Reader 的请求头 → pagesift 的抓取选项和输出参数(照 jina-ai/reader README 的“Using request
2//! headers”逐条映射)。认识但做不到的报 `UNSUPPORTED_OPTION`,不悄悄忽略;不认识的 `x-` 头不报错
3//! (中间层常加自己的头)。
4
5use axum::http::HeaderMap;
6use serde_json::{Value, json};
7
8use crate::error::ApiError;
9use crate::types::{Format, ScrapeOptions};
10
11/// `x-respond-with`。
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum RespondWith {
14    /// 没给:pagesift 一直以来的输出(纯 Markdown)。
15    Default,
16    Markdown,
17    Html,
18    Text,
19    /// YAML front matter(title、description、url)+ 正文 Markdown。
20    Frontmatter,
21    /// 同上,但不做正文识别(整页)。
22    MarkdownFrontmatter,
23    /// 截图(视口 / 整页),返回 `data:` URL。
24    Screenshot {
25        full_page: bool,
26    },
27}
28
29/// `x-retain-images`、`x-retain-links`、`x-retain-media` 的取值。
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Retain {
32    All,
33    None,
34    /// 图片只留 alt、链接只留锚文字、媒体只留标签。
35    Text,
36    /// 媒体改成图片语法。
37    Image,
38}
39
40/// `x-markdown-chunking`。
41#[derive(Debug, Clone, PartialEq)]
42pub enum Chunking {
43    Headings(u8),
44    /// `s1`…`s5`:块级结构切分,`s1` 最粗。
45    Structured(u8),
46}
47
48/// 除抓取选项之外,决定怎样写响应的参数。
49#[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    /// `Some(false)` 去重,`Some(true)` 全部(`x-with-links-summary: all`)。
58    pub links_summary: Option<bool>,
59    pub images_summary: Option<bool>,
60    pub chunking: Option<Chunking>,
61    /// `x-assert-status-code`:页面状态码不是它就报错。
62    pub assert_status: Option<u16>,
63    /// 解析请求头时产生的警告(预设里做不到的项)。
64    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
93/// `x-preset` 的选项包:只填调用方没有显式给的项(照 Jina cookbooks.md 的表)。
94fn preset(name: &str) -> Result<&'static [(&'static str, &'static str)], ApiError> {
95    Ok(match name {
96        "reader" => &[
97            // Jina 的 reader 还有 retainMedia: html、detachInvisibles、removeOverlay,pagesift 做不到,
98            // 由 parse 写 preset_partial 警告;预设不能因此让请求失败
99            ("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
131/// 解析请求头。`x-proxy-url` 只接受配置里的出口名(交给引擎按名字查,查不到就拒绝),
132/// 任意代理地址一律拒绝,防止借道打内网。
133pub fn parse(headers: &HeaderMap) -> Result<(ScrapeOptions, JinaParams), ApiError> {
134    // 预设只补没给的头:先把预设展开成一份头表,调用方的头覆盖它
135    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")) // Jina 的旧名字
167        .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        // Jina 的 markdown、html、text 都是整页(不经 readability);frontmatter 是正文
193        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        // html 不执行脚本:直接 HTTP;其余档位要等页面上的变化,只有浏览器做得到
246        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        // Jina:x-timeout ≥ 20 时隐含 network-idle
254        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        // Jina 把 Set-Cookie 形式的值转成请求 Cookie;带 Cookie 的请求不读写缓存
264        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        // 浏览器渲染本来就展开 iframe 和 shadow DOM;HTTP 抓取做不到
294        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    // Turndown 的 discarded:链接只留文字,等于 x-retain-links: text
324    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)) // [协议] Jina 的 h1…h5、s1…s5
365            };
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
404/// pagesift 的 Markdown 固定写法。请求头要的和它一样就接受,不一样就报不支持(没有配置写法的开关)。
405const 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
413/// 认识但做不到的请求头:明确报不支持(设为否定值的除外)。
414fn 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    // robots.txt 一直遵守,用的是 pagesift 自己的 User-Agent;不能换成调用方指定的爬虫名
460    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
470/// [协议] Jina README:`x-timeout` ≥ 20 秒隐含 `network-idle`。
471const JINA_NETWORK_IDLE_TIMEOUT_SECS: u64 = 20;
472/// [协议] Jina README:`x-markdown-chunking: true` 等同 `h3`…(README 写“true / h1 … h5”,默认按 h3 的例子)
473const JINA_DEFAULT_HEADING_LEVEL: u8 = 3;
474/// [协议] Jina cookbooks.md:`structured` 从 `s3` 开始
475const JINA_DEFAULT_STRUCTURED_LEVEL: u8 = 3;
476
477/// 分块选项(交给引擎的 `chunking`)。结构切分的五档对应 Markdown 由粗到细的五层结构:
478/// s1 节(各级标题处)、s2 段落(空行;表格、代码块、标题各自成块)、s3 行、s4 句子、s5 子句(标点)。
479fn 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 }), // [协议] Markdown 的最深标题级别
483        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        // 预设的 retain-links: text 被调用方的 all 覆盖,其余照预设
526        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}