Skip to main content

pagesift_engine/engine/
office.rs

1//! Office、邮件、压缩包、文本类 → 文档:抓到的(`/v2/scrape`)和上传的(`/v2/parse`)走同一段代码。
2//! 转换在 pagesift-office 里;EPUB 的 XHTML 章节和邮件的 HTML 正文交给和网页相同的 HTML 提取器
3//! (保留整页,不做正文筛选)。压缩包成员和邮件附件回到这里递归转换,内层 PDF 只读文字层。
4
5use pagesift_office::{Converted, Format};
6
7use super::*;
8
9/// [策略] 先验包 v0:0.81 的现值。没给 `timeout`、这个工人上转换的样本还不够时一份文档的转换时限;
10/// 样本够了以后按"字节数 → 耗时"的预测上界([`pagesift_stat::size_time`])。
11const DEFAULT_OFFICE_TIMEOUT: Duration = Duration::from_secs(30);
12
13thread_local! {
14    /// 这个 CPU 工人上 Office 转换的耗时样本(工人本地,不加锁)。
15    static OFFICE_TIME: std::cell::RefCell<pagesift_stat::size_time::SizeTime> =
16        std::cell::RefCell::default();
17}
18
19/// 转换的截止时间:学到的预测上界和请求的截止时间取早的;还没学到时和 0.81 一样
20/// (有请求截止时间就用它,没有就用先验)。
21fn office_deadline(bytes: usize, started: Instant, request: Option<Instant>) -> Instant {
22    let learned = OFFICE_TIME
23        .with_borrow(|m| m.timeout(bytes, pagesift_stat::Delta::DEFAULT))
24        .map(|t| started + t);
25    match (request, learned) {
26        (Some(r), Some(l)) => r.min(l),
27        (Some(r), None) => r,
28        (None, Some(l)) => l,
29        (None, None) => started + DEFAULT_OFFICE_TIMEOUT,
30    }
31}
32
33/// 把一份 Office/EPUB/CSV 文档转成 Markdown,供引擎和评测共用。`deadline` 到了报超时。
34/// CSV 的字符集按正文探测;知道 Content-Type 时用 [`office_markdown_from`]。
35pub fn office_markdown(
36    bytes: &[u8],
37    format: Format,
38    deadline: Option<Instant>,
39) -> Result<Converted, pagesift_office::Error> {
40    office_markdown_from(bytes, format, None, deadline)
41}
42
43/// HTML 转 Markdown:保留整页,相对路径按包里的位置解析。
44fn html_to_markdown(xhtml: &str, path: &str) -> String {
45    let base = Url::parse("file:///")
46        .ok()
47        .and_then(|b| b.join(&path.replace(' ', "%20")).ok());
48    let opts = pagesift_content::Options {
49        base_url: base,
50        only_main_content: false,
51        detect_barrier: false,
52        ..Default::default()
53    };
54    pagesift_content::process(xhtml, &opts).markdown
55}
56
57/// 压缩包成员或邮件附件:按内容和文件名认类型,转不了的返回 `None`(只留在清单里)。
58fn member_markdown(
59    bytes: &[u8],
60    name: &str,
61    room: usize,
62    deadline: Option<Instant>,
63) -> Result<Option<Converted>, pagesift_office::Error> {
64    use pagesift_office::Error as E;
65    if bytes.starts_with(b"%PDF-") {
66        let popts = pagesift_pdf::Options {
67            timeout: deadline.map(|d| d.saturating_duration_since(Instant::now())),
68            ..Default::default()
69        };
70        return match pagesift_pdf::extract_with(bytes, &popts) {
71            Ok(pdf) => Ok(Some(Converted {
72                parts: pdf.pages.len(),
73                markdown: pdf.markdown,
74                title: pdf.metadata.title,
75                ..Default::default()
76            })),
77            Err(pagesift_pdf::Error::Timeout) => Err(E::Timeout),
78            Err(e) => Err(E::Malformed(e.to_string())),
79        };
80    }
81    let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
82    if matches!(ext.as_deref(), Some("html" | "htm" | "xhtml")) {
83        let decoded = pagesift_net::decode(bytes, None, None);
84        return Ok(Some(Converted {
85            markdown: html_to_markdown(&decoded.text, name),
86            ..Default::default()
87        }));
88    }
89    let Some(format) = Format::detect(bytes, Some(name), None) else {
90        return Ok(None);
91    };
92    convert_with_room(bytes, format, None, deadline, Some(room)).map(Some)
93}
94
95fn convert_with_room(
96    bytes: &[u8],
97    format: Format,
98    content_type: Option<&str>,
99    deadline: Option<Instant>,
100    room: Option<usize>,
101) -> Result<Converted, pagesift_office::Error> {
102    let member = |data: &[u8], name: &str, room: usize| member_markdown(data, name, room, deadline);
103    let mut opts = pagesift_office::Options {
104        deadline,
105        html: Some(&html_to_markdown),
106        member: Some(&member),
107        ..Default::default()
108    };
109    if let Some(room) = room {
110        opts.limits.max_output_bytes = room;
111    }
112    if format.decodes_text() {
113        let (text, warning) = csv_text::decode(bytes, content_type);
114        let mut doc = pagesift_office::convert_text(&text, format, &opts)?;
115        doc.warnings.extend(warning);
116        return Ok(doc);
117    }
118    pagesift_office::convert(bytes, format, &opts)
119}
120
121/// 同 [`office_markdown`],另给文件来源的 Content-Type(响应头或上传时的):CSV 按它的
122/// `charset` 解码,和网页一样;声明和正文对不上时纠正并给 `decode:` 警告。
123pub fn office_markdown_from(
124    bytes: &[u8],
125    format: Format,
126    content_type: Option<&str>,
127    deadline: Option<Instant>,
128) -> Result<Converted, pagesift_office::Error> {
129    convert_with_room(bytes, format, content_type, deadline, None)
130}
131
132/// Office/EPUB 里的媒体文件作为附件:来源是部件路径(与 Markdown 里的图片引用一致,空格写成 `%20`)。
133fn office_media(
134    bytes: &[u8],
135    format: Format,
136) -> (Vec<pagesift_api::types::Attachment>, Vec<String>) {
137    let opts = pagesift_office::Options::default();
138    let mut out = Vec::new();
139    let mut warnings = Vec::new();
140    let list = match pagesift_office::media(bytes, format, &opts) {
141        Ok(list) => list,
142        Err(e) => return (out, vec![format!("images_not_exported: {e}")]),
143    };
144    for m in list {
145        let name = m.file_name();
146        let ext = name.rsplit_once('.').map_or("bin", |(_, e)| e);
147        match pagesift_office::media_bytes(bytes, &m.part, &opts) {
148            Ok(Some(data)) => out.push(pagesift_api::types::Attachment::with_hash(
149                m.hash.clone(),
150                ext,
151                data,
152                Some(m.part.replace(' ', "%20")),
153            )),
154            Ok(None) => warnings.push(format!("image_not_exported: {}: missing part", m.part)),
155            Err(e) => warnings.push(format!("image_not_exported: {}: {e}", m.part)),
156        }
157    }
158    (out, warnings)
159}
160
161/// 文件名或网址路径的扩展名、Content-Type、内容一起判断是不是要转换的文档。
162/// 文本类不走这里(网上的 text/plain、XML、JSON 还是网页路径);
163/// 服务器明说是 HTML 的不信扩展名(`/data.csv` 返回的也可能是网页)。
164pub(super) fn office_format(body: &[u8], name: &str, content_type: Option<&str>) -> Option<Format> {
165    let html = content_type.is_some_and(|c| c.to_ascii_lowercase().contains("html"));
166    Format::detect(body, Some(name), content_type)
167        .filter(|f| f.fetchable() && !(html && f.is_text_based()))
168}
169
170/// 一份待转换的文档:字节、格式,以及来源的 Content-Type(响应头或上传字段;CSV 按它的 `charset` 解码)。
171pub(super) struct OfficeInput {
172    pub(super) bytes: Vec<u8>,
173    pub(super) format: Format,
174    pub(super) content_type: Option<String>,
175}
176
177impl Scraper {
178    pub(super) async fn office_document(
179        &self,
180        input: OfficeInput,
181        opts: &ScrapeOptions,
182        (mut meta, ext): (Metadata, PagesiftMeta),
183        mut p: Progress,
184        deadline: Option<Instant>,
185    ) -> Result<Document, ApiError> {
186        let stage = self
187            .policy
188            .fetcher()
189            .acquire_stage(pagesift_net::NetworkStage::Parse)
190            .await;
191        let t = Instant::now();
192        let format = input.format;
193        let export = opts.export_images;
194        let (result, media) = cpu_task(move || {
195            let started = Instant::now();
196            let bytes = input.bytes.len();
197            let deadline = office_deadline(bytes, started, deadline);
198            let result = office_markdown_from(
199                &input.bytes,
200                input.format,
201                input.content_type.as_deref(),
202                Some(deadline),
203            );
204            if result.is_ok() {
205                OFFICE_TIME.with_borrow_mut(|m| m.observe(bytes, started.elapsed()));
206            }
207            // `exportImages`:文档里的媒体原样从 ZIP 取出
208            let media =
209                (export && result.is_ok()).then(|| office_media(&input.bytes, input.format));
210            (result, media)
211        })
212        .await?;
213        let doc = match result {
214            Ok(doc) => {
215                stage.finish(pagesift_net::StageOutcome::Success);
216                doc
217            }
218            Err(error) => {
219                stage.finish(pagesift_net::StageOutcome::DeterministicFailure);
220                return Err(office_error(format, error));
221            }
222        };
223        p.timings.insert(format.extension().into(), ms(t.elapsed()));
224        p.warnings.extend(doc.warnings);
225        let (attachments, media_warnings) = media.unwrap_or_default();
226        p.warnings.extend(media_warnings);
227        meta.title = doc.title;
228        Ok(finish(
229            Document {
230                markdown: opts.wants("markdown").then_some(doc.markdown),
231                attachments,
232                ..Default::default()
233            },
234            meta,
235            ext,
236            p.timings,
237            p.warnings,
238            None,
239            false,
240            p.started,
241        ))
242    }
243}
244
245/// 文件本身的问题是 4xx:坏文件 400 INVALID_REQUEST,加密或用到不支持的东西 400 UNSUPPORTED_OPTION,
246/// 超限 413,超时 408。
247pub(super) fn office_error(format: Format, e: pagesift_office::Error) -> ApiError {
248    use pagesift_office::Error as E;
249    let code = match e {
250        E::Encrypted | E::Unsupported(_) => ErrorCode::UnsupportedOption,
251        E::TooLarge(_) => ErrorCode::TooLarge,
252        E::Timeout => ErrorCode::Timeout,
253        _ => ErrorCode::InvalidRequest,
254    };
255    ApiError::new(
256        code,
257        format!("could not read {}: {e}", format.extension().to_uppercase()),
258    )
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn office_errors_are_caller_errors() {
267        use pagesift_office::Error as E;
268        for (e, code) in [
269            (E::Encrypted, ErrorCode::UnsupportedOption),
270            (E::TooLarge("x"), ErrorCode::TooLarge),
271            (E::Timeout, ErrorCode::Timeout),
272            (E::Malformed("x".into()), ErrorCode::InvalidRequest),
273        ] {
274            assert_eq!(office_error(Format::Docx, e).code, code);
275        }
276    }
277
278    #[test]
279    fn html_responses_are_not_csv() {
280        assert_eq!(
281            office_format(b"a,b", "/data.csv", Some("text/plain")),
282            Some(Format::Csv)
283        );
284        assert_eq!(office_format(b"<p>", "/data.csv", Some("text/html")), None);
285        assert_eq!(office_format(b"<p>", "/page", None), None);
286        // 文本类不抢网页路径;邮件和 RTF 这种内容明确的才接
287        assert_eq!(office_format(b"x", "/a.txt", Some("text/plain")), None);
288        assert_eq!(
289            office_format(b"{}", "/a.json", Some("application/json")),
290            None
291        );
292        assert_eq!(office_format(b"<p>", "/m.eml", Some("text/html")), None);
293        assert_eq!(
294            office_format(b"Subject: x\n\ny", "/m.eml", None),
295            Some(Format::Eml)
296        );
297        assert_eq!(office_format(b"{\\rtf1 x}", "/f", None), Some(Format::Rtf));
298    }
299}