Skip to main content

pagesift_engine/engine/interact/
mod.rs

1//! `/v2/scrape/{id}/interact`:保留的浏览器页面上继续执行动作。
2//!
3//! - `keepSession: true` 的抓取走完整的抓取流程,只是渲染完不关页面([`KEEP`] 把页面带出来);
4//! - 没有要求保留的抓取只记下网址和选项,第一次 interact 时重新打开(Firecrawl 的回放),警告 `session_replayed`;
5//! - 只用 Chrome:无障碍树、鼠标键盘事件只在 Chrome 上验证过;
6//! - 点击可能跳到别的页面:动作前查当前页的 robots 并按主机限速,动作后跳走了再查一次。
7//!
8//! 回收的规则见 [`registry`],`prompt` 的流程见 [`act`]。
9
10use std::collections::BTreeSet;
11use std::sync::Mutex;
12
13use pagesift_api::types::{
14    InteractRequest, InteractResponse, InteractStopped, SESSION_REPLAYED, session_gone,
15};
16use pagesift_cdp::KeptPage;
17use serde_json::Value;
18
19use super::*;
20
21mod act;
22mod plan;
23mod registry;
24
25pub use registry::InteractLimits;
26pub(crate) use registry::Sessions;
27use registry::{OpenPage, PageState, Record};
28
29tokio::task_local! {
30    /// 这次抓取要保留页面:渲染成功后页面放进这里,交回给 [`Scraper::open_page`]。
31    /// 用 task-local 是因为页面要穿过整条抓取流程(钩子、缓存、审计),它不是抓取选项的一部分;
32    /// 抓取流程到 `render` 之间没有 spawn。
33    pub(super) static KEEP: Arc<KeepSlot>;
34}
35
36#[derive(Default)]
37pub(super) struct KeepSlot(Mutex<Option<(KeptPage, Arc<BrowserPool>)>>);
38
39impl KeepSlot {
40    /// 换内核或换出口重试时后来的覆盖先来的(旧页面丢弃时自己关)。
41    pub(super) fn put(&self, page: KeptPage, pool: Arc<BrowserPool>) {
42        *self.0.lock().unwrap_or_else(|e| e.into_inner()) = Some((page, pool));
43    }
44
45    fn take(&self) -> Option<(KeptPage, Arc<BrowserPool>)> {
46        self.0.lock().unwrap_or_else(|e| e.into_inner()).take()
47    }
48}
49
50/// 这个内核能不能做 interact。
51pub(super) fn interact_capable(cfg: &PoolConfig) -> bool {
52    cfg.engine.unwrap_or(pagesift_cdp::EngineKind::Chrome) == pagesift_cdp::EngineKind::Chrome
53}
54
55/// 记录表的字节上限:内核链里最大的“一个浏览器加一个标签”的内存([`registry`])。
56pub(super) fn record_budget(chain: &[PoolConfig]) -> usize {
57    let mb = chain
58        .iter()
59        .map(|c| {
60            let f = c
61                .engine
62                .unwrap_or(pagesift_cdp::EngineKind::Chrome)
63                .footprint();
64            f.browser_mb + f.tab_mb
65        })
66        .max()
67        .unwrap_or(0) as usize;
68    (mb * 1024 * 1024).min(MAX_RECORD_BYTES)
69}
70
71/// [策略] 记录表最多占这么多内存:每次 `/v2/scrape` 都存一条,没人用 interact 的服务也不该涨上去
72const MAX_RECORD_BYTES: usize = 16 * 1024 * 1024;
73
74/// 把用过的秘密的值换回占位符:原文、URL 编码(GET 表单把它放进查询串)、HTML 转义几种写法都换。
75fn redact(text: &str, secrets: &[(&str, &str)]) -> String {
76    let mut out = text.to_string();
77    for (name, value) in secrets {
78        if value.is_empty() {
79            continue;
80        }
81        let holder = format!("{{{{secret:{name}}}}}");
82        let encoded: String = url::form_urlencoded::byte_serialize(value.as_bytes()).collect();
83        let escaped = value
84            .replace('&', "&amp;")
85            .replace('<', "&lt;")
86            .replace('>', "&gt;")
87            .replace('"', "&quot;");
88        for form in [value, &encoded.as_str(), &escaped.as_str()] {
89            if out.contains(*form) {
90                out = out.replace(*form, &holder);
91            }
92        }
93    }
94    out
95}
96
97fn redact_json(v: &mut Value, secrets: &[(&str, &str)]) {
98    match v {
99        Value::String(s) => *s = redact(s, secrets),
100        Value::Array(a) => a.iter_mut().for_each(|x| redact_json(x, secrets)),
101        Value::Object(m) => m.values_mut().for_each(|x| redact_json(x, secrets)),
102        _ => {}
103    }
104}
105
106fn has_secret(action: &Value) -> bool {
107    action
108        .get("text")
109        .and_then(Value::as_str)
110        .is_some_and(|t| t.contains("{{secret:"))
111}
112
113fn is_script(action: &Value) -> bool {
114    action.get("type").and_then(Value::as_str) == Some("executeJavascript")
115}
116
117/// 文字里出现的 `{{secret:NAME}}` 的名字。
118fn secret_names(text: &str) -> impl Iterator<Item = &str> {
119    text.split("{{secret:")
120        .skip(1)
121        .filter_map(|rest| rest.split_once("}}").map(|(name, _)| name))
122}
123
124impl Scraper {
125    /// 能不能做 interact:内核链里有 Chrome。
126    pub(super) fn interact_available(&self) -> bool {
127        self.browsers.iter().any(|(c, _)| interact_capable(c))
128    }
129
130    fn interact_unavailable(&self) -> ApiError {
131        ApiError::unsupported(
132            "interact needs Chrome: no Chrome or Chromium was found on this machine (run `pagesift doctor`)",
133        )
134    }
135
136    /// 内核链里第一个能做 interact 的池。
137    async fn interact_pool(&self) -> Result<Arc<BrowserPool>, ApiError> {
138        for i in 0..self.browsers.len() {
139            if interact_capable(&self.browsers[i].0)
140                && let Some(pool) = self.browser(i).await
141            {
142                return Ok(pool);
143            }
144        }
145        Err(self.interact_unavailable())
146    }
147
148    /// 按内存和标签名额腾出一个保留页面的位置并占住,页面登记后放开;腾不出来时报 429。
149    async fn admit_page(&self) -> Result<registry::Reserved, ApiError> {
150        let pool = self.interact_pool().await?;
151        let cap = self.sessions.sweep(Some(pool.session_room()), 1).await;
152        self.sessions.reserve(cap).map_err(|open| {
153            ApiError::new(
154                ErrorCode::ConcurrencyLimitExceeded,
155                format!(
156                    "cannot keep another browser session: {open} are open and in use, and this machine's memory and browser tabs admit {cap}"
157                ),
158            )
159        })
160    }
161
162    /// 走完整的抓取流程打开页面,不关。
163    async fn open_page(
164        &self,
165        url: &str,
166        opts: &ScrapeOptions,
167        mode: RobotsMode,
168    ) -> Result<(Document, KeptPage, Arc<BrowserPool>), ApiError> {
169        let mut inner = opts.clone();
170        inner.engine = Some("browser".into());
171        // 会话要的是一个活的页面,缓存里没有
172        inner.max_age = Some(0);
173        inner.store_in_cache = false;
174        let slot = Arc::new(KeepSlot::default());
175        let doc = KEEP
176            .scope(slot.clone(), self.scrape_with(url, &inner, mode))
177            .await?;
178        let (page, pool) = slot.take().ok_or_else(|| {
179            ApiError::unsupported(
180                "this page was answered without a browser (a rule chose the engine, or the content is not a web page), so there is no session to keep",
181            )
182        })?;
183        Ok((doc, page, pool))
184    }
185
186    fn opened(
187        &self,
188        page: KeptPage,
189        pool: Arc<BrowserPool>,
190        doc: &Document,
191        url: &str,
192        opts: &ScrapeOptions,
193    ) -> Box<OpenPage> {
194        self.sessions.start_reaper();
195        let now = Instant::now();
196        Box::new(OpenPage {
197            page,
198            pool,
199            opened: now,
200            last_used: now,
201            url: doc.metadata.url.clone().unwrap_or_else(|| url.to_string()),
202            secrets: BTreeSet::new(),
203            scripted: opts.actions.iter().any(is_script),
204        })
205    }
206
207    /// `/v2/scrape`:`keep` 时保留页面;否则只记下回放信息。结果带 `metadata.scrapeId`。
208    pub async fn scrape_session(
209        &self,
210        url: &str,
211        opts: &ScrapeOptions,
212        keep: bool,
213        owner: Option<&str>,
214    ) -> Result<Document, ApiError> {
215        if !self.interact_available() {
216            if keep {
217                return Err(self.interact_unavailable());
218            }
219            return self.scrape(url, opts).await;
220        }
221        let mode = self
222            .policy
223            .robots_mode(RequestKind::Scrape, opts.ignore_robots_txt)
224            .map_err(policy_error)?;
225        if !keep {
226            let mut doc = self.scrape_with(url, opts, mode).await?;
227            let id = self
228                .sessions
229                .insert(None, owner, url, opts, mode, PageState::NotOpened);
230            doc.metadata.scrape_id = Some(id);
231            return Ok(doc);
232        }
233        if opts.engine.as_deref() == Some("http") {
234            return Err(ApiError::invalid(
235                "keepSession keeps a browser page and cannot be combined with engine \"http\"",
236            ));
237        }
238        let _reserved = self.admit_page().await?;
239        let (mut doc, page, pool) = self.open_page(url, opts, mode).await?;
240        let open = self.opened(page, pool, &doc, url, opts);
241        let id = self
242            .sessions
243            .insert(None, owner, url, opts, mode, PageState::Open(open));
244        doc.metadata.scrape_id = Some(id);
245        Ok(doc)
246    }
247
248    /// 没有这个 id 的记录、但请求带了 `url`:Firecrawl 的“原来的上下文没有了,在这个网址开新会话”。
249    fn record_for(
250        &self,
251        id: &str,
252        req: &InteractRequest,
253        owner: Option<&str>,
254    ) -> Result<Arc<Record>, ApiError> {
255        match (self.sessions.get(id, owner), req.url.as_deref()) {
256            (Ok(r), _) => Ok(r),
257            (Err(e), Some(url))
258                if matches!(e.code, ErrorCode::SessionGone | ErrorCode::NotFound)
259                    && pagesift_api::types::is_scrape_id(id) =>
260            {
261                let opts = ScrapeOptions::default();
262                let mode = self
263                    .policy
264                    .robots_mode(RequestKind::Scrape, opts.ignore_robots_txt)
265                    .map_err(policy_error)?;
266                self.sessions
267                    .insert(Some(id), owner, url, &opts, mode, PageState::NotOpened);
268                self.sessions.get(id, owner)
269            }
270            (Err(e), _) => Err(e),
271        }
272    }
273
274    pub async fn interact(
275        &self,
276        id: &str,
277        req: &InteractRequest,
278        owner: Option<&str>,
279    ) -> Result<InteractResponse, ApiError> {
280        req.check()?;
281        if !self.interact_available() {
282            return Err(self.interact_unavailable());
283        }
284        if req.prompt.is_some() && self.llm.is_none() {
285            return Err(ApiError::unsupported(
286                "prompt needs an LLM: set [llm] in pagesift.toml, or send `actions`",
287            ));
288        }
289        let actions = req.actions.clone().unwrap_or_default();
290        let record = self.record_for(id, req, owner)?;
291        let mut resp = InteractResponse::default();
292        let limit = req.time_limit();
293        let deadline = tokio::time::Instant::now() + limit;
294        let timed_out = |resp: &mut InteractResponse| {
295            resp.killed = true;
296            resp.fail(format!(
297                "the call did not finish within {} s; the session is still open",
298                limit.as_secs_f64()
299            ));
300        };
301        // 同一个会话上的调用排队执行:页面只有一个
302        let Ok(mut state) = tokio::time::timeout_at(deadline, record.state.lock()).await else {
303            timed_out(&mut resp);
304            return Ok(resp);
305        };
306        if let PageState::Gone(why) = &*state {
307            return Err(session_gone(format!(
308                "the browser session of scrape {id} has ended: {why}"
309            )));
310        }
311        if matches!(&*state, PageState::NotOpened) {
312            let opts = replay_options(&record.opts, limit);
313            let opening = async {
314                let reserved = self.admit_page().await?;
315                let opened = self.open_page(&record.url, &opts, record.mode).await?;
316                Ok::<_, ApiError>((reserved, opened))
317            };
318            let (_reserved, (doc, page, pool)) =
319                match tokio::time::timeout_at(deadline, opening).await {
320                    Ok(r) => r?,
321                    Err(_) => {
322                        timed_out(&mut resp);
323                        return Ok(resp);
324                    }
325                };
326            *state = PageState::Open(self.opened(page, pool, &doc, &record.url, &opts));
327            self.sessions.mark_open(id, &record);
328            resp.warnings.push(format!(
329                "{SESSION_REPLAYED}: the scrape did not keep its page (keepSession), so {} was opened again; the page may differ from the scrape result",
330                record.url
331            ));
332        }
333        let PageState::Open(open) = &mut *state else {
334            return Err(session_gone(format!(
335                "the browser session of scrape {id} has ended"
336            )));
337        };
338        let run = async {
339            match &req.prompt {
340                Some(prompt) => self.run_prompt(&record, open, prompt, &mut resp).await,
341                None => self.run_actions(&record, open, actions, &mut resp).await,
342            }
343        };
344        let outcome = tokio::time::timeout_at(deadline, run).await;
345        open.last_used = Instant::now();
346        let alive = open.page.is_alive();
347        let redacting: Vec<(String, String)> = open
348            .secrets
349            .iter()
350            .filter_map(|n| self.secrets.get(n).map(|v| (n.clone(), v.value.clone())))
351            .collect();
352        // 错误消息也可能带着秘密(GET 表单的查询串跳到了 robots 不允许的页面)
353        let outcome = outcome.map(|r| {
354            r.map_err(|mut e| {
355                let pairs: Vec<(&str, &str)> = redacting
356                    .iter()
357                    .map(|(n, v)| (n.as_str(), v.as_str()))
358                    .collect();
359                e.message = redact(&e.message, &pairs);
360                e
361            })
362        });
363        match outcome {
364            Err(_) => timed_out(&mut resp),
365            Ok(Ok(())) => {}
366            Ok(Err(e)) => {
367                let closes = e.code == ErrorCode::RobotsDisallowed || !alive;
368                if closes {
369                    let why = if alive {
370                        "an action navigated to a page robots.txt disallows".to_string()
371                    } else {
372                        "the browser exited".to_string()
373                    };
374                    let old = std::mem::replace(&mut *state, PageState::Gone(why.clone()));
375                    drop(state);
376                    if let PageState::Open(o) = old {
377                        o.page.close().await;
378                    }
379                    self.sessions.mark_closed(id);
380                    return Err(if alive {
381                        e
382                    } else {
383                        session_gone(format!(
384                            "the browser session of scrape {id} has ended: {why}"
385                        ))
386                    });
387                }
388                return Err(e);
389            }
390        }
391        drop(state);
392        finish_response(resp, &redacting)
393    }
394
395    pub async fn stop_interact(
396        &self,
397        id: &str,
398        owner: Option<&str>,
399    ) -> Result<InteractStopped, ApiError> {
400        let not_found = |detail: &str| {
401            ApiError::new(
402                ErrorCode::NotFound,
403                format!("no browser session for scrape {id}: {detail}"),
404            )
405        };
406        let record = self.sessions.get(id, owner).map_err(|e| match e.code {
407            // Firecrawl 的 DELETE 只有 403、404:查不到就是 404,原因写在消息里
408            ErrorCode::SessionGone => ApiError::new(ErrorCode::NotFound, e.message),
409            _ => e,
410        })?;
411        let mut state = record.state.lock().await;
412        match &*state {
413            PageState::NotOpened => return Err(not_found("no session was started")),
414            PageState::Gone(why) => return Err(not_found(&format!("it has ended: {why}"))),
415            PageState::Open(_) => {}
416        }
417        let old = std::mem::replace(&mut *state, PageState::Gone("it was stopped".into()));
418        drop(state);
419        let PageState::Open(open) = old else {
420            return Err(not_found("no session was started"));
421        };
422        let held = open.opened.elapsed();
423        open.page.close().await;
424        self.sessions.mark_closed(id);
425        Ok(InteractStopped {
426            success: true,
427            session_duration_ms: held.as_millis() as u64,
428            credits_billed: 0,
429        })
430    }
431
432    /// 动作前:查当前页的 robots,按主机限速;限速名额拿到动作结束。
433    async fn polite_permit(
434        &self,
435        record: &Record,
436        current: &str,
437    ) -> Result<pagesift_policy::Permit, ApiError> {
438        let target = Url::parse(current).map_err(|e| ApiError::invalid(e.to_string()))?;
439        let delay = match record.mode {
440            RobotsMode::Respect => self
441                .policy
442                .check_robots(&target)
443                .await
444                .map_err(policy_error)?,
445            RobotsMode::Ignore => None,
446        };
447        let host = target.host_str().unwrap_or_default().to_string();
448        Ok(self.policy.limiter().acquire(&host, delay).await)
449    }
450
451    async fn run_actions(
452        &self,
453        record: &Record,
454        open: &mut OpenPage,
455        mut actions: Vec<Value>,
456        resp: &mut InteractResponse,
457    ) -> Result<(), ApiError> {
458        // 占位符还在时先校验整批:写错了一步都不执行
459        pagesift_cdp::parse_actions(&Value::Array(actions.clone()))
460            .map_err(|e| ApiError::invalid(e.to_string()))?;
461        let filling = actions.iter().any(has_secret);
462        let scripting = actions.iter().any(is_script);
463        // 脚本能读到页面上填好的值,也能装监听器等着值填进来
464        if scripting && (filling || !open.secrets.is_empty()) || filling && open.scripted {
465            return Err(ApiError::invalid(
466                "a session cannot both run executeJavascript and use {{secret:...}}",
467            ));
468        }
469        open.scripted |= scripting;
470        for a in &actions {
471            if let Some(text) = a.get("text").and_then(Value::as_str) {
472                open.secrets.extend(secret_names(text).map(str::to_string));
473            }
474        }
475        let permit = self.polite_permit(record, &open.url.clone()).await?;
476        let started = Instant::now();
477        let mut out = pagesift_cdp::ActionOutputs::default();
478        let mut failed = None;
479        let mut rest = actions.as_mut_slice();
480        while !rest.is_empty() && failed.is_none() {
481            // 要填秘密的动作单独跑:前面的动作可能已经跳走,值只能填给页面此刻所在的主机
482            let n = if has_secret(&rest[0]) {
483                1
484            } else {
485                rest.iter().position(has_secret).unwrap_or(rest.len())
486            };
487            let (chunk, tail) = std::mem::take(&mut rest).split_at_mut(n);
488            rest = tail;
489            if has_secret(&chunk[0]) {
490                match open.page.capture().await {
491                    Ok(page) => {
492                        super::autoschema::fill_secrets(chunk, &self.secrets, &page.final_url)?
493                    }
494                    Err(e) => {
495                        failed = Some(e);
496                        break;
497                    }
498                }
499            }
500            let parsed = pagesift_cdp::parse_actions(&Value::Array(chunk.to_vec()))
501                .map_err(|e| ApiError::invalid(e.to_string()))?;
502            match open.page.run_actions(&parsed).await {
503                Ok(o) => {
504                    out.screenshots.extend(o.screenshots);
505                    out.scrapes.extend(o.scrapes);
506                    out.javascript_returns.extend(o.javascript_returns);
507                    out.pdfs.extend(o.pdfs);
508                }
509                Err(e) => failed = Some(e),
510            }
511        }
512        match failed {
513            None => {
514                resp.result = out.javascript_returns.last().map(|j| j.value.to_string());
515                resp.actions = Some(out.to_firecrawl_json());
516            }
517            Some(e) => action_failure(e, resp)?,
518        }
519        drop(permit);
520        self.fill_page(record, open, resp, started).await
521    }
522
523    /// 动作之后的页面:跳走了再查一次 robots,然后转成文档放进响应。
524    async fn fill_page(
525        &self,
526        record: &Record,
527        open: &mut OpenPage,
528        resp: &mut InteractResponse,
529        started: Instant,
530    ) -> Result<(), ApiError> {
531        let mut page = match open.page.capture().await {
532            Ok(p) => p,
533            Err(e) => {
534                // 这次的动作结果比取不到页面更重要:错误如实写上,已有的失败原因不覆盖
535                if open.page.is_alive() {
536                    if resp.error.is_none() {
537                        resp.fail(format!("could not read the page after the actions: {e}"));
538                    }
539                    return Ok(());
540                }
541                return Err(ApiError::new(render_error_code(&e), e.to_string()));
542            }
543        };
544        if page.final_url != open.url {
545            if record.mode == RobotsMode::Respect
546                && let Ok(u) = Url::parse(&page.final_url)
547            {
548                self.policy.check_robots(&u).await.map_err(policy_error)?;
549            }
550            open.url = page.final_url.clone();
551        }
552        let secrets = self.named_secrets(&open.secrets);
553        if !secrets.is_empty() {
554            // 转 Markdown 前就换掉:转义之后可能对不上
555            page.html = redact(&page.html, &secrets);
556        }
557        let opts = page_options(&record.opts);
558        let doc = self
559            .live_document(page, &record.url, &opts, Vec::new(), started)
560            .await?;
561        resp.data = Some(doc);
562        Ok(())
563    }
564
565    fn named_secrets<'a>(&'a self, names: &'a BTreeSet<String>) -> Vec<(&'a str, &'a str)> {
566        names
567            .iter()
568            .filter_map(|n| self.secrets.get(n).map(|v| (n.as_str(), v.value.as_str())))
569            .collect()
570    }
571}
572
573/// 动作失败:选择器找不到之类写进响应(会话还在),其余按错误类别报。
574fn action_failure(e: pagesift_cdp::Error, resp: &mut InteractResponse) -> Result<(), ApiError> {
575    use pagesift_cdp::Error as E;
576    match e {
577        E::Action { .. } | E::Protocol { .. } => {
578            resp.fail(e.to_string());
579            Ok(())
580        }
581        E::InvalidOptions(m) => Err(ApiError::invalid(m)),
582        E::ExecuteJavascriptDisabled => Err(ApiError::unsupported(e.to_string())),
583        other => Err(ApiError::new(render_error_code(&other), other.to_string())),
584    }
585}
586
587/// 回放用的选项:时限不超过这次调用的时限。
588fn replay_options(opts: &ScrapeOptions, limit: Duration) -> ScrapeOptions {
589    let mut o = opts.clone();
590    let ms = limit.as_millis() as u64;
591    o.timeout = Some(o.timeout.map_or(ms, |t| t.min(ms)));
592    o
593}
594
595/// 动作之后取页面用的选项:原来的格式里不调模型、不靠缓存的那些;一个都没有时给 Markdown。
596fn page_options(opts: &ScrapeOptions) -> ScrapeOptions {
597    let mut o = opts.clone();
598    o.formats.retain(|f| {
599        matches!(
600            f.kind.as_str(),
601            "markdown" | "html" | "rawHtml" | "links" | "images"
602        )
603    });
604    if o.formats.is_empty() {
605        o.formats.push(pagesift_api::types::Format::new("markdown"));
606    }
607    o
608}
609
610/// 响应里用过的秘密换回占位符:整页、动作产出、错误信息和网址都可能带上(输入框的内容、表单 GET 的查询串)。
611fn finish_response(
612    resp: InteractResponse,
613    secrets: &[(String, String)],
614) -> Result<InteractResponse, ApiError> {
615    if secrets.is_empty() {
616        return Ok(resp);
617    }
618    let pairs: Vec<(&str, &str)> = secrets
619        .iter()
620        .map(|(n, v)| (n.as_str(), v.as_str()))
621        .collect();
622    let mut v = serde_json::to_value(&resp)
623        .map_err(|e| ApiError::new(ErrorCode::Internal, e.to_string()))?;
624    redact_json(&mut v, &pairs);
625    serde_json::from_value(v).map_err(|e| ApiError::new(ErrorCode::Internal, e.to_string()))
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn secrets_are_named_and_redacted() {
634        let names: Vec<&str> = secret_names("log in as {{secret:USER}} / {{secret:PW}}").collect();
635        assert_eq!(names, ["USER", "PW"]);
636        assert_eq!(secret_names("nothing here").count(), 0);
637        let pairs = [("PW", "hunter2"), ("EMPTY", "")];
638        assert_eq!(redact("value=hunter2;", &pairs), "value={{secret:PW}};");
639        let mut v = serde_json::json!({"a": ["hunter2"], "b": {"c": "x hunter2"}, "n": 1});
640        redact_json(&mut v, &pairs);
641        assert_eq!(
642            v,
643            serde_json::json!({"a": ["{{secret:PW}}"], "b": {"c": "x {{secret:PW}}"}, "n": 1})
644        );
645    }
646
647    #[test]
648    fn page_options_drop_llm_formats() {
649        let o: ScrapeOptions =
650            serde_json::from_value(serde_json::json!({"formats": ["summary", "links"]})).unwrap();
651        let kinds: Vec<_> = page_options(&o)
652            .formats
653            .iter()
654            .map(|f| f.kind.clone())
655            .collect();
656        assert_eq!(kinds, ["links"]);
657        let o: ScrapeOptions =
658            serde_json::from_value(serde_json::json!({"formats": ["summary"]})).unwrap();
659        assert!(page_options(&o).wants("markdown"));
660    }
661}