Skip to main content

pagesift_api/
engine.rs

1//! 抓取引擎接口。API 层只做协议、校验和任务管理,真正的抓取由实现这个 trait 的引擎完成。
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use crate::error::ApiError;
8use crate::types::{
9    AgentFailure, AgentOutcome, AgentRequest, AgentStep, CrawlRequest, CrawlSummary, Document,
10    ExtractRequest, InteractRequest, InteractResponse, InteractStopped, MapLink, MapRequest,
11    ScrapeOptions, SearchRequest, Upload,
12};
13use serde_json::Value;
14
15pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
16
17pub trait Engine: Send + Sync + 'static {
18    fn scrape<'a>(
19        &'a self,
20        url: &'a str,
21        opts: &'a ScrapeOptions,
22    ) -> BoxFuture<'a, Result<Document, ApiError>>;
23
24    /// `/v2/parse`:解析调用方上传的文件(PDF、HTML),返回和 scrape 相同的文档。
25    /// `opts` 已经过 [`crate::check_parse_options`] 校验。
26    fn parse<'a>(
27        &'a self,
28        _file: Upload,
29        _opts: &'a ScrapeOptions,
30    ) -> BoxFuture<'a, Result<Document, ApiError>> {
31        Box::pin(async {
32            Err(ApiError::unsupported(
33                "parse is not available in this build",
34            ))
35        })
36    }
37
38    fn map<'a>(&'a self, _req: &'a MapRequest) -> BoxFuture<'a, Result<Vec<MapLink>, ApiError>> {
39        Box::pin(async { Err(ApiError::unsupported("map is not available in this build")) })
40    }
41
42    /// 整站爬取:抓到一页、出错一页、发现新 URL 时都通过 `sink` 回报,直到爬完或被取消。
43    fn crawl<'a>(
44        &'a self,
45        _req: &'a CrawlRequest,
46        _sink: Arc<dyn CrawlSink>,
47    ) -> BoxFuture<'a, Result<(), ApiError>> {
48        Box::pin(async {
49            Err(ApiError::unsupported(
50                "crawl is not available in this build",
51            ))
52        })
53    }
54
55    /// `/v2/search`:返回 Firecrawl 的 `data`(`{ "web": [...] }`)。
56    fn search<'a>(&'a self, _req: &'a SearchRequest) -> BoxFuture<'a, Result<Value, ApiError>> {
57        Box::pin(async {
58            Err(ApiError::unsupported(
59                "search is not configured: set [search] in pagesift.toml",
60            ))
61        })
62    }
63
64    /// `/v2/extract`:返回提取出的 JSON。
65    fn extract<'a>(&'a self, _req: &'a ExtractRequest) -> BoxFuture<'a, Result<Value, ApiError>> {
66        Box::pin(async {
67            Err(ApiError::unsupported(
68                "extract needs an LLM: set [llm] in pagesift.toml",
69            ))
70        })
71    }
72
73    /// `/v2/agent` 开跑前的检查:没配 LLM、没有 `urls` 又没配搜索时,提交时就报错。
74    fn agent_check(&self, _req: &AgentRequest) -> Result<(), ApiError> {
75        Err(ApiError::unsupported(
76            "agent needs an LLM: set [llm] in pagesift.toml",
77        ))
78    }
79
80    /// `/v2/agent`:跑完一次(中途出错也回已经攒下的证据,错误在 `AgentOutcome::error`)。
81    fn agent<'a>(
82        &'a self,
83        _req: &'a AgentRequest,
84        _sink: Arc<dyn AgentSink>,
85    ) -> BoxFuture<'a, AgentOutcome> {
86        Box::pin(async {
87            AgentOutcome {
88                error: Some(AgentFailure::from(&ApiError::unsupported(
89                    "agent needs an LLM: set [llm] in pagesift.toml",
90                ))),
91                ..Default::default()
92            }
93        })
94    }
95
96    /// 引擎支持的 `formats`;请求了不支持的格式时返回 UNSUPPORTED_OPTION,不悄悄忽略。
97    fn supported_formats(&self) -> &'static [&'static str] {
98        &["markdown", "html", "rawHtml", "links", "images"]
99    }
100
101    /// 是否支持 `actions`(需要浏览器渲染)。
102    fn supports_actions(&self) -> bool {
103        false
104    }
105
106    /// 不支持 `actions` 时给调用方的说明。
107    fn actions_unavailable(&self) -> &'static str {
108        "actions need browser rendering, which is not available in this build"
109    }
110
111    /// 是否支持 `/v2/scrape/{id}/interact`。支持时 `/v2/scrape` 走 [`Engine::scrape_session`]。
112    fn supports_interact(&self) -> bool {
113        false
114    }
115
116    /// `/v2/scrape`:抓一页,结果带 `metadata.scrapeId`。`keep` 时保留浏览器页面(`keepSession`),
117    /// 否则只记下回放信息。`owner` 是调用方的账号,之后的 interact 只认同一个账号。
118    fn scrape_session<'a>(
119        &'a self,
120        _url: &'a str,
121        _opts: &'a ScrapeOptions,
122        _keep: bool,
123        _owner: Option<&'a str>,
124    ) -> BoxFuture<'a, Result<Document, ApiError>> {
125        Box::pin(async {
126            Err(ApiError::unsupported(
127                "keepSession is not available in this build",
128            ))
129        })
130    }
131
132    /// `/v2/observe`:页面上和指令对应的候选元素(选择器、角色、名称),返回候选和用量警告。
133    fn observe<'a>(
134        &'a self,
135        _url: &'a str,
136        _instruction: &'a str,
137    ) -> BoxFuture<'a, Result<crate::types::Observed, ApiError>> {
138        Box::pin(async {
139            Err(ApiError::unsupported(
140                "observe is not available in this build",
141            ))
142        })
143    }
144
145    /// `/v2/similar`:和例子结构相同的兄弟元素。
146    fn find_similar<'a>(
147        &'a self,
148        _url: &'a str,
149        _selector: &'a str,
150        _opts: &'a ScrapeOptions,
151    ) -> BoxFuture<'a, Result<crate::types::SimilarData, ApiError>> {
152        Box::pin(async {
153            Err(ApiError::unsupported(
154                "find_similar is not available in this build",
155            ))
156        })
157    }
158
159    fn interact<'a>(
160        &'a self,
161        _id: &'a str,
162        _req: &'a InteractRequest,
163        _owner: Option<&'a str>,
164    ) -> BoxFuture<'a, Result<InteractResponse, ApiError>> {
165        Box::pin(async {
166            Err(ApiError::unsupported(
167                "interact is not available in this build",
168            ))
169        })
170    }
171
172    fn stop_interact<'a>(
173        &'a self,
174        _id: &'a str,
175        _owner: Option<&'a str>,
176    ) -> BoxFuture<'a, Result<InteractStopped, ApiError>> {
177        Box::pin(async {
178            Err(ApiError::unsupported(
179                "interact is not available in this build",
180            ))
181        })
182    }
183
184    /// 统一准入现在允许的在途抓取数:批量任务的并发按它定,用户的
185    /// `max_batch_concurrency` 只作上限。引擎还没接准入时返回 `None`,API 用配置或先验。
186    fn admitted_concurrency(&self) -> Option<usize> {
187        None
188    }
189}
190
191/// agent 运行的回报和额度,由 API 层的任务管理实现;`()` 是不回报、不限额的实现。
192pub trait AgentSink: Send + Sync {
193    /// 一次工具调用结束;`credits` 是到现在为止用掉的额度。
194    fn step<'a>(&'a self, _step: &'a AgentStep, _credits: u64) -> BoxFuture<'a, ()> {
195        Box::pin(async {})
196    }
197    /// 调用方要求取消:在两步之间生效。
198    fn cancelled(&self) -> bool {
199        false
200    }
201    /// 抓到一页:计入调用方的页面配额。
202    fn charge_page(&self) -> BoxFuture<'_, ()> {
203        Box::pin(async {})
204    }
205    /// 调用方今天还能抓多少页;没有配额时 `None`。
206    fn pages_left(&self) -> Option<u64> {
207        None
208    }
209}
210
211impl AgentSink for () {}
212
213/// 整站爬取的进度回报,由 API 层的任务管理实现。
214pub trait CrawlSink: Send + Sync {
215    fn page(&self, doc: Document) -> BoxFuture<'_, ()>;
216    fn error<'a>(&'a self, url: &'a str, err: ApiError) -> BoxFuture<'a, ()>;
217    /// 已经确定要抓的页面总数(随着发现新链接增长)。
218    fn total(&self, total: usize) -> BoxFuture<'_, ()>;
219    fn cancelled(&self) -> BoxFuture<'_, bool>;
220
221    /// 断点续爬:任务是从中断处恢复的时候返回上次的进度,引擎据此重建队列和去重表,
222    /// 不再抓已经完成的页面。新任务返回 `None`。
223    fn resume(&self) -> Option<CrawlResume> {
224        None
225    }
226
227    /// 是否需要 [`CrawlSink::enqueued`] 回报(持久化的任务需要)。不需要时引擎不必收集。
228    fn tracks_frontier(&self) -> bool {
229        false
230    }
231
232    /// 新进入队列的 URL 和发现深度,按入队顺序。只在 [`CrawlSink::tracks_frontier`] 为真时调用。
233    fn enqueued(&self, _urls: Vec<(String, usize)>) -> BoxFuture<'_, ()> {
234        Box::pin(async {})
235    }
236
237    /// 爬取结束时的说明(停止原因等);只有带查询的爬取会调用。
238    fn summary(&self, _summary: CrawlSummary) -> BoxFuture<'_, ()> {
239        Box::pin(async {})
240    }
241}
242
243/// 中断的爬取任务恢复时的进度。完成的页面以文档的 `metadata.sourceURL`(失败的以出错的 URL)为准。
244#[derive(Debug, Clone, Default, PartialEq)]
245pub struct CrawlResume {
246    /// 入过队的全部 URL,按入队顺序(去重表)。
247    pub enqueued: Vec<String>,
248    /// 入了队但还没完成的 URL 和发现深度,按入队顺序。中断时正在抓的页面也在这里。
249    pub pending: Vec<(String, usize)>,
250    /// 已经完成的页数(成功和失败都算),计入 `limit`。
251    pub done: usize,
252}