Skip to main content

sdsconv_core/converter/
extractor.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use crate::error::SdsError;
5
6/// Default maximum characters to send to the LLM — consistent with ConvertConfig::default().
7const DEFAULT_MAX_LLM_CHARS: usize = 80_000;
8
9const MAX_BINARY_INPUT_BYTES: u64 = 500 * 1024 * 1024; // 500 MB for binary formats
10const MAX_TEXT_INPUT_BYTES: u64 = 100 * 1024 * 1024; // 100 MB for text formats
11
12/// Character count below which we assume a PDF is image-only and attempt OCR.
13const OCR_FALLBACK_THRESHOLD: usize = 200;
14
15/// Prefer pdftotext (Poppler) over pdf-extract when pdftotext gives at least
16/// this fraction of pdf-extract's character count.  pdftotext is a more mature
17/// extractor that preserves label-value adjacency in table-layout PDFs (e.g.
18/// it emits "推奨用途及び使用上の制限 シランカップリング剤" on one line, whereas
19/// pdf-extract lists column headers as a block and values separately later).
20/// Only fall back to pdf-extract when pdftotext gives substantially less text
21/// (likely because it failed or the PDF uses non-standard encoding).
22const PDFTOTEXT_MIN_FRACTION: usize = 75; // prefer pdftotext when pt ≥ 75 % of pdf-extract
23
24pub enum InputFormat {
25    Pdf,
26    Docx,
27    Txt,
28    Xlsx,
29    Html,
30    Url,
31}
32
33pub fn detect_format(path: &Path) -> Result<InputFormat, SdsError> {
34    detect_format_str(
35        path.to_str()
36            .ok_or_else(|| SdsError::UnsupportedFormat("(invalid path)".to_string()))?,
37    )
38}
39
40/// Detect input format from a file path or URL string.
41pub fn detect_format_str(input: &str) -> Result<InputFormat, SdsError> {
42    if input.starts_with("http://") || input.starts_with("https://") {
43        return Ok(InputFormat::Url);
44    }
45    let ext = std::path::Path::new(input)
46        .extension()
47        .and_then(|e| e.to_str())
48        .map(|e| e.to_ascii_lowercase());
49    match ext.as_deref() {
50        Some("pdf") => Ok(InputFormat::Pdf),
51        Some("docx") => Ok(InputFormat::Docx),
52        Some("txt") => Ok(InputFormat::Txt),
53        Some("xlsx") | Some("xls") | Some("xlsm") => Ok(InputFormat::Xlsx),
54        Some("html") | Some("htm") => Ok(InputFormat::Html),
55        Some(e) => Err(SdsError::UnsupportedFormat(e.to_string())),
56        None => Err(SdsError::UnsupportedFormat("(no extension)".to_string())),
57    }
58}
59
60pub async fn extract_text(path: &Path) -> Result<String, SdsError> {
61    extract_text_limited(path, DEFAULT_MAX_LLM_CHARS).await
62}
63
64/// Detect the language of a document by extracting a small text sample and running heuristics.
65///
66/// Uses the first 5 000 characters — sufficient for language detection without a full extraction.
67/// Returns an error only if the file cannot be read or has an unsupported format.
68pub async fn detect_language_from_file(path: &Path) -> Result<crate::language::Language, SdsError> {
69    let sample = extract_text_limited(path, 5_000).await.unwrap_or_default();
70    Ok(crate::language::detect_language(&sample))
71}
72
73/// Detect the language of an HTML page fetched from a URL.
74pub async fn detect_language_from_url(url: &str) -> Result<crate::language::Language, SdsError> {
75    let sample = extract_text_from_url_limited(url, 5_000).await.unwrap_or_default();
76    Ok(crate::language::detect_language(&sample))
77}
78
79/// Extract text from a URL (fetches HTML and strips tags).
80pub async fn extract_text_from_url(url: &str) -> Result<String, SdsError> {
81    extract_text_from_url_limited(url, DEFAULT_MAX_LLM_CHARS).await
82}
83
84/// Returns `true` for private, loopback, link-local, and metadata IP addresses/hostnames.
85fn is_private_host(host: &str) -> bool {
86    use std::net::IpAddr;
87    // Block numeric IPs that are private/loopback/metadata
88    if let Ok(ip) = host.parse::<IpAddr>() {
89        return match ip {
90            IpAddr::V4(v4) => {
91                v4.is_loopback()        // 127.x.x.x
92                || v4.is_private()      // 10.x, 172.16-31.x, 192.168.x
93                || v4.is_link_local()   // 169.254.x.x (AWS metadata)
94                || v4.is_unspecified()  // 0.0.0.0
95                || v4.is_broadcast()
96            }
97            IpAddr::V6(v6) => {
98                v6.is_loopback()
99                    || v6.is_unspecified()
100                    // fc00::/7  — unique-local (ULA)
101                    || v6.segments()[0] & 0xfe00 == 0xfc00
102                    // fe80::/10 — link-local
103                    || v6.segments()[0] & 0xffc0 == 0xfe80
104                    // ::ffff:0:0/96 — IPv4-mapped; check the embedded IPv4 address
105                    || {
106                        let segs = v6.segments();
107                        segs[0] == 0 && segs[1] == 0 && segs[2] == 0
108                            && segs[3] == 0 && segs[4] == 0 && segs[5] == 0xffff
109                            && {
110                                let v4 = std::net::Ipv4Addr::new(
111                                    (segs[6] >> 8) as u8, segs[6] as u8,
112                                    (segs[7] >> 8) as u8, segs[7] as u8,
113                                );
114                                v4.is_loopback() || v4.is_private() || v4.is_link_local()
115                            }
116                    }
117            }
118        };
119    }
120    // Block well-known metadata hostnames
121    matches!(host,
122        "localhost" | "metadata.google.internal" | "instance-data"
123    )
124}
125
126/// Like [`extract_text_from_url`] but truncates to `max_chars` after cleaning.
127pub async fn extract_text_from_url_limited(url: &str, max_chars: usize) -> Result<String, SdsError> {
128    // SSRF guard: reject private/loopback/metadata addresses before making a request.
129    let parsed = reqwest::Url::parse(url)
130        .map_err(|e| SdsError::Extract(format!("Invalid URL: {e}")))?;
131    let host = parsed
132        .host_str()
133        .ok_or_else(|| SdsError::Extract("URL has no host".into()))?;
134    if is_private_host(host) {
135        return Err(SdsError::Extract(
136            "URL points to a private/reserved address".into(),
137        ));
138    }
139
140    const MAX_BODY_BYTES: usize = 50 * 1024 * 1024; // 50 MB
141
142    let response = shared_http_client()
143        .get(url)
144        .send()
145        .await
146        .map_err(|e| SdsError::Extract(format!("HTTP GET failed: {e}")))?;
147
148    // Reject responses whose Content-Length header exceeds the limit.
149    if let Some(content_length) = response.content_length() {
150        if content_length > MAX_BODY_BYTES as u64 {
151            return Err(SdsError::Extract(format!(
152                "URL response too large ({} bytes, limit 50 MB)", content_length
153            )));
154        }
155    }
156
157    let bytes = response
158        .bytes()
159        .await
160        .map_err(|e| SdsError::Extract(format!("response body failed: {e}")))?;
161    if bytes.len() > MAX_BODY_BYTES {
162        return Err(SdsError::Extract(format!(
163            "URL response too large ({} bytes, limit 50 MB)", bytes.len()
164        )));
165    }
166    let html = String::from_utf8_lossy(&bytes).into_owned();
167    let raw = extract_text_from_html_str(&html);
168    Ok(clean_extracted_text(&raw, max_chars))
169}
170
171/// Like [`extract_text`] but truncates to `max_chars` after cleaning.
172pub async fn extract_text_limited(path: &Path, max_chars: usize) -> Result<String, SdsError> {
173    let input_format = detect_format(path)?;
174    let size_limit = match &input_format {
175        InputFormat::Txt | InputFormat::Html => MAX_TEXT_INPUT_BYTES,
176        _ => MAX_BINARY_INPUT_BYTES,
177    };
178    let file_size = std::fs::metadata(path)
179        .map_err(|e| SdsError::Extract(format!("file stat failed: {e}")))?
180        .len();
181    if file_size > size_limit {
182        return Err(SdsError::Extract(format!(
183            "input file too large ({} bytes, limit {} MB)",
184            file_size,
185            size_limit / 1024 / 1024
186        )));
187    }
188    let raw = match input_format {
189        InputFormat::Pdf => {
190            let path_a = path.to_path_buf();
191            let path_b = path.to_path_buf();
192            let path_c = path.to_path_buf();
193
194            // ① Try text-based extraction with pdf-extract.
195            //   pdf-extract can panic on unsupported font encodings (e.g. 90ms-RKSJ-H,
196            //   FromUtf8Error for Shift-JIS PDFs).  We use catch_unwind so that:
197            //   a) the panic does not propagate as a tokio task failure, and
198            //   b) a debug log message is emitted instead of a noisy panic backtrace.
199            //   (Rust's default panic hook still prints to stderr before catch_unwind runs;
200            //   this is a known limitation — the fallback logic is correct regardless.)
201            let raw = tokio::task::spawn_blocking(move || {
202                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
203                    pdf_extract::extract_text(&path_a)
204                })) {
205                    Ok(Ok(text)) => text,
206                    Ok(Err(e)) => {
207                        tracing::debug!("pdf-extract error: {e}; will try pdftotext/OCR");
208                        String::new()
209                    }
210                    Err(_) => {
211                        tracing::debug!(
212                            "pdf-extract panicked (unsupported font encoding?); \
213                             will try pdftotext/OCR"
214                        );
215                        String::new()
216                    }
217                }
218            })
219            .await
220            .unwrap_or_default(); // JoinError on task cancellation → empty string
221
222            let pdf_extract_chars = raw.trim().chars().count();
223
224            // ② Always try pdftotext (poppler) regardless of pdf-extract result.
225            //   pdftotext handles CID/Shift-JIS fonts and preserves table cell values
226            //   that pdf-extract sometimes drops (returning only column headers).
227            //   If pdftotext returns substantially more text (≥ PDFTOTEXT_PREFER_RATIO ×),
228            //   we prefer it — this catches table-layout PDFs where pdf-extract is garbled.
229            //   pdftotext is silently skipped if not installed.
230            let pt_text = tokio::task::spawn_blocking(move || pdftotext_fallback(&path_b))
231                .await
232                .unwrap_or(None);
233
234            let raw = if let Some(pt) = pt_text {
235                let pt_chars = pt.trim().chars().count();
236                // Prefer pdftotext when:
237                //   a) pdf-extract returned sparse/empty text (< OCR_FALLBACK_THRESHOLD), OR
238                //   b) pdftotext gives at least PDFTOTEXT_MIN_FRACTION % as many chars.
239                // Rationale: pdftotext (Poppler) preserves label-value adjacency in table-layout
240                // PDFs, making it easier for the LLM to pair fields with their values.
241                // Only keep pdf-extract when pdftotext gives substantially less text (likely failed).
242                let prefer_pt = pdf_extract_chars < OCR_FALLBACK_THRESHOLD
243                    || (pt_chars >= OCR_FALLBACK_THRESHOLD
244                        && pt_chars.saturating_mul(100)
245                            >= pdf_extract_chars.saturating_mul(PDFTOTEXT_MIN_FRACTION));
246                if prefer_pt {
247                    tracing::debug!(
248                        pdf_extract_chars,
249                        pt_chars,
250                        "pdftotext preferred over pdf-extract"
251                    );
252                    pt
253                } else {
254                    tracing::debug!(
255                        pdf_extract_chars,
256                        pt_chars,
257                        "pdf-extract preferred over pdftotext (insufficient pdftotext output)"
258                    );
259                    raw
260                }
261            } else {
262                raw
263            };
264
265            // ③ Sparse text: likely a scanned PDF — attempt OCR fallback.
266            if raw.trim().chars().count() < OCR_FALLBACK_THRESHOLD {
267                let ocr = tokio::task::spawn_blocking(move || ocr_pdf_with_tesseract(&path_c))
268                    .await
269                    .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())));
270
271                match ocr {
272                    Ok(text) if !text.trim().is_empty() => text,
273                    Err(e) => {
274                        // Tesseract is unavailable — signal that vision OCR may help.
275                        return Err(SdsError::ImageOnlyPdf(e.to_string()));
276                    }
277                    Ok(_) => raw, // tesseract ran but produced nothing; keep sparse text
278                }
279            } else {
280                raw
281            }
282        }
283        InputFormat::Docx => {
284            let path = path.to_path_buf();
285            tokio::task::spawn_blocking(move || extract_text_from_docx(&path))
286                .await
287                .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
288        }
289        InputFormat::Txt => {
290            let path = path.to_path_buf();
291            tokio::task::spawn_blocking(move || {
292                std::fs::read_to_string(&path).map_err(|e| SdsError::Extract(e.to_string()))
293            })
294            .await
295            .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
296        }
297        InputFormat::Xlsx => {
298            let path = path.to_path_buf();
299            tokio::task::spawn_blocking(move || extract_text_from_xlsx(&path))
300                .await
301                .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
302        }
303        InputFormat::Html => {
304            let path = path.to_path_buf();
305            tokio::task::spawn_blocking(move || {
306                let html = std::fs::read_to_string(&path)
307                    .map_err(|e| SdsError::Extract(e.to_string()))?;
308                Ok(extract_text_from_html_str(&html))
309            })
310            .await
311            .unwrap_or_else(|e| Err(SdsError::Extract(e.to_string())))?
312        }
313        InputFormat::Url => {
314            return Err(SdsError::Extract(
315                "Use extract_text_from_url() for URL inputs".to_string(),
316            ));
317        }
318    };
319    Ok(clean_extracted_text(&raw, max_chars))
320}
321
322// ---------------------------------------------------------------------------
323// pdftotext fallback (poppler)
324// ---------------------------------------------------------------------------
325
326/// Extract text from a PDF using the `pdftotext` CLI (part of poppler-utils).
327///
328/// This is used as a middle tier when `pdf-extract` fails or returns sparse text
329/// on PDFs that use CID fonts (e.g. Shift-JIS encoded Japanese PDFs).  The `-utf8`
330/// flag instructs pdftotext to always output UTF-8, handling the encoding conversion
331/// that `pdf-extract` cannot perform.
332///
333/// Returns `None` if:
334/// - `pdftotext` is not installed (silently ignored; caller falls through to OCR)
335/// - the command exits with a non-zero status
336/// - the output is empty or whitespace-only
337fn pdftotext_fallback(path: &Path) -> Option<String> {
338    let path_str = path.to_str()?;
339    // Note: `-utf8` was removed because poppler ≥ 24 no longer recognises it
340    // and exits with code 99 (unknown option), causing silent fallback failure.
341    // Modern pdftotext writes UTF-8 by default.
342    let out = std::process::Command::new("pdftotext")
343        .args([path_str, "-"]) // "-" writes to stdout
344        .output()
345        .ok()?;
346    if !out.status.success() {
347        return None;
348    }
349    let text = String::from_utf8_lossy(&out.stdout).into_owned();
350    if text.trim().is_empty() { None } else { Some(text) }
351}
352
353// ---------------------------------------------------------------------------
354// OCR fallback (pdftoppm + tesseract CLI)
355// ---------------------------------------------------------------------------
356
357/// Convert every page of a PDF to PNG with pdftoppm, then OCR with tesseract.
358///
359/// Returns `Err` with an install hint if either tool is absent.
360/// Returns `Ok("")` only when tesseract ran but produced no text.
361fn ocr_pdf_with_tesseract(pdf_path: &Path) -> Result<String, SdsError> {
362    use std::path::PathBuf;
363
364    let tmp = tempfile::tempdir()
365        .map_err(|e| SdsError::Extract(format!("OCR tmpdir: {e}")))?;
366
367    let page_prefix = tmp.path().join("page");
368
369    // Step 1 — rasterise PDF pages to PNG at 300 dpi.
370    let status = std::process::Command::new("pdftoppm")
371        .args([
372            "-r", "300",
373            "-png",
374            pdf_path.to_str().unwrap_or(""),
375            page_prefix.to_str().unwrap_or(""),
376        ])
377        .status()
378        .map_err(|e| SdsError::Extract(format!(
379            "pdftoppm not found ({e}). \
380             Install poppler: `brew install poppler` / `apt install poppler-utils` / \
381             https://github.com/oschwartz10612/poppler-windows/releases"
382        )))?;
383
384    if !status.success() {
385        return Err(SdsError::Extract(format!("pdftoppm exited with {status}")));
386    }
387
388    // Step 2 — collect PNG files in page order.
389    let mut pngs: Vec<PathBuf> = std::fs::read_dir(tmp.path())
390        .map_err(|e| SdsError::Extract(e.to_string()))?
391        .filter_map(|e| e.ok())
392        .map(|e| e.path())
393        .filter(|p| {
394            p.extension()
395                .and_then(|e| e.to_str())
396                .map(|e| e.eq_ignore_ascii_case("png"))
397                .unwrap_or(false)
398        })
399        .collect();
400    pngs.sort();
401
402    if pngs.is_empty() {
403        return Err(SdsError::Extract("pdftoppm produced no images".to_string()));
404    }
405
406    // Step 3 — OCR each page and concatenate.
407    let ocr_stem = tmp.path().join("ocr");
408    let mut combined = String::new();
409
410    for png in &pngs {
411        // Try jpn+eng first (common for Japanese SDS); fall back to eng-only.
412        let ok = try_tesseract(png, &ocr_stem, "jpn+eng")
413            .or_else(|_| try_tesseract(png, &ocr_stem, "eng"))
414            .is_ok();
415
416        if ok {
417            let txt = ocr_stem.with_extension("txt");
418            if let Ok(page_text) = std::fs::read_to_string(&txt) {
419                combined.push_str(&page_text);
420                combined.push('\n');
421            }
422        }
423    }
424
425    // tmp dir is cleaned up on drop.
426    Ok(combined)
427}
428
429fn try_tesseract(input: &Path, output_stem: &Path, lang: &str) -> Result<(), SdsError> {
430    let status = std::process::Command::new("tesseract")
431        .arg(input.to_str().unwrap_or(""))
432        .arg(output_stem.to_str().unwrap_or(""))
433        .args(["-l", lang])
434        .status()
435        .map_err(|e| SdsError::Extract(format!(
436            "tesseract not found ({e}). \
437             Install: `brew install tesseract tesseract-lang` / \
438             `apt install tesseract-ocr tesseract-ocr-jpn` / \
439             https://github.com/UB-Mannheim/tesseract/wiki"
440        )))?;
441
442    if !status.success() {
443        return Err(SdsError::Extract(format!(
444            "tesseract exited with {status} (lang={lang}; \
445             ensure the language pack is installed)"
446        )));
447    }
448    Ok(())
449}
450
451/// Clean and condense raw extracted text before sending to the LLM.
452///
453/// Three passes:
454///   1. Remove separator lines, collapse blank runs, strip control chars.
455///   2. Deduplicate repeated short lines (PDF page headers/footers).
456///   3. Truncate to `max_chars` at a UTF-8 char boundary.
457pub fn clean_extracted_text(text: &str, max_chars: usize) -> String {
458    // Pass 1 — noise removal
459    let mut out = String::with_capacity(text.len().min(max_chars + 1024));
460    let mut blank_run = 0usize;
461
462    for line in text.lines() {
463        let trimmed = line.trim();
464
465        // Drop control characters and zero-width spaces but keep CJK / Latin content
466        let trimmed: String = trimmed
467            .chars()
468            .filter(|&c| c >= ' ' || c == '\t')
469            .collect();
470        let trimmed = trimmed.trim();
471
472        // Drop lines that are purely visual separators (─━=─-*•· etc.)
473        if !trimmed.is_empty()
474            && trimmed.chars().all(|c| {
475                matches!(c,
476                    '-' | '=' | '_' | '*' | '─' | '━' | '╌' | '╍'
477                    | '┄' | '┅' | '┈' | '┉' | '╴' | '╶' | '╸'
478                    | '·' | '•' | '~' | '/' | '\\' | '|' | '+' | '#'
479                )
480            })
481            && trimmed.chars().count() >= 3
482        {
483            continue;
484        }
485
486        if trimmed.is_empty() {
487            blank_run += 1;
488            if blank_run <= 1 {
489                out.push('\n');
490            }
491        } else {
492            blank_run = 0;
493            out.push_str(trimmed);
494            out.push('\n');
495        }
496    }
497
498    // Pass 2 — deduplicate repeated short lines (page headers / footers)
499    // Any non-empty line ≤ 80 chars appearing 4+ times is treated as a repeated header/footer.
500    // Threshold is 4 (not 3) because a value can legitimately appear 3 times in an SDS:
501    // e.g. the same phone number may be listed for 電話番号, 緊急時の電話番号, and FAX番号.
502    // Empty lines (paragraph separators) are never deduplicated.
503    {
504        let mut freq: HashMap<String, usize> = HashMap::new();
505        for line in out.lines() {
506            // Blank lines are structural separators — always pass them through.
507            if !line.is_empty() && line.len() <= 80 {
508                *freq.entry(line.to_string()).or_default() += 1;
509            }
510        }
511        let mut first_seen: HashSet<String> = HashSet::new();
512        let mut deduped = String::with_capacity(out.len());
513        for line in out.lines() {
514            let count = freq.get(line).copied().unwrap_or(1);
515            if !line.is_empty() && line.len() <= 80 && count >= 4 {
516                if first_seen.insert(line.to_string()) {
517                    deduped.push_str(line);
518                    deduped.push('\n');
519                }
520            } else {
521                deduped.push_str(line);
522                deduped.push('\n');
523            }
524        }
525        out = deduped;
526    }
527
528    // Pass 3 — truncate to max_chars (counted in Unicode scalar values, not bytes)
529    // so that Japanese/CJK text is not cut at 1/3 of the intended character limit.
530    if out.chars().count() > max_chars {
531        let byte_offset = out
532            .char_indices()
533            .nth(max_chars)
534            .map(|(i, _)| i)
535            .unwrap_or(out.len());
536        out.truncate(byte_offset);
537        out.push_str("\n[テキスト省略]\n");
538    }
539
540    out
541}
542
543pub fn extract_text_from_docx(path: &Path) -> Result<String, SdsError> {
544    let docx = docx_rust::DocxFile::from_file(path)
545        .map_err(|e| SdsError::Docx(format!("open failed: {e:?}")))?;
546    let docx = docx
547        .parse()
548        .map_err(|e| SdsError::Docx(format!("parse failed: {e:?}")))?;
549    Ok(docx.document.body.text())
550}
551
552pub fn extract_text_from_xlsx(path: &Path) -> Result<String, SdsError> {
553    use calamine::{open_workbook_auto, Reader};
554    let mut wb = open_workbook_auto(path)
555        .map_err(|e| SdsError::Extract(format!("xlsx open failed: {e}")))?;
556    let mut out = String::new();
557    for sheet_name in wb.sheet_names().to_owned() {
558        if let Ok(range) = wb.worksheet_range(&sheet_name) {
559            for row in range.rows() {
560                let cells: Vec<String> = row
561                    .iter()
562                    .map(|c| c.to_string())
563                    .filter(|s| !s.is_empty())
564                    .collect();
565                if !cells.is_empty() {
566                    out.push_str(&cells.join("\t"));
567                    out.push('\n');
568                }
569            }
570        }
571    }
572    Ok(out)
573}
574
575/// Returns lazily-initialised, shared CSS selectors for HTML extraction.
576fn html_selectors() -> (&'static scraper::Selector, &'static scraper::Selector, &'static scraper::Selector) {
577    use scraper::Selector;
578    use std::sync::OnceLock;
579    static ROW:  OnceLock<Selector> = OnceLock::new();
580    static CELL: OnceLock<Selector> = OnceLock::new();
581    static BODY: OnceLock<Selector> = OnceLock::new();
582    (
583        ROW.get_or_init(||  Selector::parse("tr").expect("static CSS selector is valid")),
584        CELL.get_or_init(|| Selector::parse("td, th").expect("static CSS selector is valid")),
585        BODY.get_or_init(|| Selector::parse("body").expect("static CSS selector is valid")),
586    )
587}
588
589/// Returns a long-lived shared `reqwest::Client` for URL fetches.
590/// Avoids creating a new client (and TLS context) on every call.
591fn shared_http_client() -> &'static reqwest::Client {
592    use std::sync::OnceLock;
593    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
594    CLIENT.get_or_init(|| {
595        reqwest::Client::builder()
596            .timeout(std::time::Duration::from_secs(60))
597            // Disable automatic redirect following to prevent SSRF via redirect chains.
598            .redirect(reqwest::redirect::Policy::none())
599            .build()
600            .expect("failed to build shared HTTP client")
601    })
602}
603
604/// Extract visible text from an HTML string, skipping script/style/nav elements.
605/// Table cells are tab-separated; rows are newline-separated.
606pub fn extract_text_from_html_str(html: &str) -> String {
607    use scraper::Html;
608
609    let document = Html::parse_document(html);
610    let (row_sel, cell_sel, body_sel) = html_selectors();
611
612    let body = match document.select(&body_sel).next() {
613        Some(b) => b,
614        None => return String::new(),
615    };
616
617    let mut out = String::new();
618
619    for node in body.children() {
620        collect_node_text(
621            scraper::ElementRef::wrap(node),
622            &row_sel,
623            &cell_sel,
624            &mut out,
625        );
626    }
627
628    out
629}
630
631fn collect_node_text(
632    node: Option<scraper::ElementRef<'_>>,
633    row_sel: &scraper::Selector,
634    cell_sel: &scraper::Selector,
635    out: &mut String,
636) {
637    let Some(el) = node else { return };
638    let tag = el.value().name();
639
640    if tag == "table" {
641        for row in el.select(row_sel) {
642            let cells: Vec<String> = row
643                .select(cell_sel)
644                .map(|c| c.text().collect::<String>().trim().to_string())
645                .filter(|s| !s.is_empty())
646                .collect();
647            if !cells.is_empty() {
648                out.push_str(&cells.join("\t"));
649                out.push('\n');
650            }
651        }
652        return;
653    }
654
655    // Skip noise elements
656    if matches!(tag, "script" | "style" | "nav" | "header" | "footer" | "noscript") {
657        return;
658    }
659
660    // For block-like elements emit a newline before and after.
661    let is_block = matches!(
662        tag,
663        "p" | "div" | "section" | "article" | "li" | "dt" | "dd"
664            | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
665            | "br" | "hr" | "blockquote" | "pre"
666    );
667
668    if is_block && !out.ends_with('\n') {
669        out.push('\n');
670    }
671
672    for child in el.children() {
673        if let Some(text) = child.value().as_text() {
674            let t = text.trim();
675            if !t.is_empty() {
676                out.push_str(t);
677                out.push(' ');
678            }
679        } else if let Some(child_el) = scraper::ElementRef::wrap(child) {
680            collect_node_text(Some(child_el), row_sel, cell_sel, out);
681        }
682    }
683
684    if is_block && !out.ends_with('\n') {
685        out.push('\n');
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn separator_lines_are_dropped() {
695        let input = "Section 1\n---\nContent\n===\nMore content\n";
696        let result = clean_extracted_text(input, 1000);
697        assert!(!result.contains("---"));
698        assert!(!result.contains("==="));
699        assert!(result.contains("Section 1"));
700        assert!(result.contains("Content"));
701    }
702
703    #[test]
704    fn multiple_blank_lines_collapse_to_one() {
705        let input = "Line A\n\n\n\nLine B\n";
706        let result = clean_extracted_text(input, 1000);
707        // Should have at most one blank line between A and B
708        assert!(!result.contains("\n\n\n"));
709        assert!(result.contains("Line A"));
710        assert!(result.contains("Line B"));
711    }
712
713    #[test]
714    fn cjk_content_passes_through() {
715        let input = "第1節 化学品の名称\n製品名:テスト化学物質\n";
716        let result = clean_extracted_text(input, 1000);
717        assert!(result.contains("第1節"));
718        assert!(result.contains("テスト化学物質"));
719    }
720
721    #[test]
722    fn truncation_lands_on_utf8_boundary() {
723        let input: String = "あ".repeat(100);
724        let result = clean_extracted_text(&input, 10);
725        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
726    }
727
728    #[test]
729    fn repeated_header_lines_deduplicated() {
730        let header = "Company Inc. SDS";
731        let mut input = String::new();
732        for i in 0..10 {
733            input.push_str(header);
734            input.push('\n');
735            input.push_str(&format!("Section {i} content\n"));
736        }
737        let result = clean_extracted_text(&input, 10_000);
738        let count = result.matches(header).count();
739        assert_eq!(count, 1, "header appeared {count} times, expected 1");
740    }
741
742    #[test]
743    fn short_non_repeated_lines_kept() {
744        let input = "Line A\nLine B\nLine C\n";
745        let result = clean_extracted_text(input, 1000);
746        assert!(result.contains("Line A"));
747        assert!(result.contains("Line B"));
748        assert!(result.contains("Line C"));
749    }
750
751    /// Bug 1: blank-line paragraph separators must survive Pass 2 deduplication.
752    /// With the unfixed code, `""` is counted in freq and hits threshold ≥ 4 when
753    /// there are 5+ sections, causing all but the first blank line to be silently dropped.
754    #[test]
755    fn blank_lines_not_removed_by_dedup() {
756        // 5 sections separated by blank lines → 4 blank lines total in freq
757        let input = "Section A content\n\nSection B content\n\nSection C content\n\nSection D content\n\nSection E content\n";
758        let result = clean_extracted_text(input, 10_000);
759        assert!(
760            result.contains("Section A content\n\nSection B content"),
761            "blank line separator between A and B was removed; result: {result:?}"
762        );
763        assert!(
764            result.contains("Section B content\n\nSection C content"),
765            "blank line separator between B and C was removed; result: {result:?}"
766        );
767    }
768
769    /// Bug 2: short values that legitimately repeat (e.g. Japanese hazard classifications)
770    /// must NOT be deduplicated.  "該当区分なし" appears once per hazard class in a real
771    /// SDS — deduping to 1 occurrence destroys hazard classification data.
772    /// Also exercises Bug 3 (byte-length vs char-count): "該当区分なし" is 6 CJK chars
773    /// = 18 UTF-8 bytes, well within 80 chars but would only be caught if char count is used.
774    #[test]
775    fn short_repeated_values_preserved() {
776        let repeated = "該当区分なし";
777        let mut input = String::new();
778        for i in 0..8 {
779            input.push_str(&format!("危険有害性クラス{i}: {repeated}\n"));
780        }
781        let result = clean_extracted_text(&input, 10_000);
782        let count = result.matches(repeated).count();
783        assert_eq!(count, 8, "short repeated value appeared {count} times, expected 8");
784    }
785}