Skip to main content

web_capture/
lib.rs

1//! # web-capture
2//!
3//! A library and CLI/microservice to render web pages as HTML, Markdown, or PNG screenshots.
4//!
5//! ## Features
6//!
7//! - Fetch HTML content from URLs
8//! - Convert HTML to Markdown
9//! - Capture PNG screenshots of web pages
10//! - Convert relative URLs to absolute URLs
11//! - Support for headless browser rendering via browser-commander
12//!
13//! ## Example
14//!
15//! ```rust,no_run
16//! use web_capture::{fetch_html, convert_html_to_markdown, capture_screenshot};
17//!
18//! #[tokio::main]
19//! async fn main() -> anyhow::Result<()> {
20//!     // Fetch HTML from a URL
21//!     let html = fetch_html("https://example.com").await?;
22//!     println!("HTML length: {}", html.len());
23//!
24//!     // Convert HTML to Markdown
25//!     let markdown = convert_html_to_markdown(&html, Some("https://example.com"))?;
26//!     println!("Markdown: {}", markdown);
27//!
28//!     // Capture a screenshot
29//!     let screenshot = capture_screenshot("https://example.com").await?;
30//!     println!("Screenshot size: {} bytes", screenshot.len());
31//!
32//!     Ok(())
33//! }
34//! ```
35
36pub mod animation;
37pub mod archive;
38pub mod batch;
39pub mod browser;
40pub mod extract_images;
41pub mod figures;
42pub mod gdocs;
43pub mod github;
44pub mod html;
45pub mod kreuzberg;
46pub mod latex;
47pub mod localize_images;
48pub mod markdown;
49pub mod metadata;
50pub mod postprocess;
51pub mod search;
52pub mod shared_dialog;
53pub mod stackoverflow;
54pub mod themed_image;
55pub mod verify;
56pub mod xpaste;
57
58use thiserror::Error;
59
60/// Version of the web-capture library
61pub const VERSION: &str = env!("CARGO_PKG_VERSION");
62
63/// Error types for web-capture operations
64#[derive(Error, Debug)]
65pub enum WebCaptureError {
66    #[error("Failed to fetch URL: {0}")]
67    FetchError(String),
68
69    #[error("Failed to parse HTML: {0}")]
70    ParseError(String),
71
72    #[error("Failed to convert to Markdown: {0}")]
73    MarkdownError(String),
74
75    #[error("Failed to capture screenshot: {0}")]
76    ScreenshotError(String),
77
78    #[error("Browser error: {0}")]
79    BrowserError(String),
80
81    #[error("Invalid URL: {0}")]
82    InvalidUrl(String),
83
84    #[error("IO error: {0}")]
85    IoError(#[from] std::io::Error),
86
87    #[error("Request error: {0}")]
88    RequestError(#[from] reqwest::Error),
89}
90
91/// Result type for web-capture operations
92pub type Result<T> = std::result::Result<T, WebCaptureError>;
93
94/// Fetch HTML content from a URL
95///
96/// This function makes a simple HTTP GET request to fetch the HTML content.
97/// For JavaScript-heavy pages, use `render_html` instead.
98///
99/// # Arguments
100///
101/// * `url` - The URL to fetch
102///
103/// # Returns
104///
105/// The HTML content as a string
106///
107/// # Errors
108///
109/// Returns an error if the fetch fails or the response cannot be decoded
110pub async fn fetch_html(url: &str) -> Result<String> {
111    html::fetch_html(url).await
112}
113
114/// Render HTML content from a URL using a headless browser
115///
116/// This function uses browser-commander to launch a headless browser,
117/// navigate to the URL, and return the rendered HTML content.
118///
119/// # Arguments
120///
121/// * `url` - The URL to render
122///
123/// # Returns
124///
125/// The rendered HTML content as a string
126///
127/// # Errors
128///
129/// Returns an error if browser operations fail
130pub async fn render_html(url: &str) -> Result<String> {
131    browser::render_html(url).await
132}
133
134/// Convert HTML content to Markdown
135///
136/// # Arguments
137///
138/// * `html` - The HTML content to convert
139/// * `base_url` - Optional base URL for converting relative URLs to absolute
140///
141/// # Returns
142///
143/// The Markdown content as a string
144///
145/// # Errors
146///
147/// Returns an error if conversion fails
148pub fn convert_html_to_markdown(html: &str, base_url: Option<&str>) -> Result<String> {
149    markdown::convert_html_to_markdown(html, base_url)
150}
151
152/// Capture a PNG screenshot of a URL
153///
154/// This function uses browser-commander to launch a headless browser,
155/// navigate to the URL, and capture a screenshot.
156///
157/// # Arguments
158///
159/// * `url` - The URL to capture
160///
161/// # Returns
162///
163/// The PNG image data as bytes
164///
165/// # Errors
166///
167/// Returns an error if browser operations fail
168pub async fn capture_screenshot(url: &str) -> Result<Vec<u8>> {
169    browser::capture_screenshot(url).await
170}
171
172/// Convert relative URLs to absolute URLs in HTML content
173///
174/// # Arguments
175///
176/// * `html` - The HTML content to process
177/// * `base_url` - The base URL to use for resolving relative URLs
178///
179/// # Returns
180///
181/// The HTML content with absolute URLs
182#[must_use]
183pub fn convert_relative_urls(html: &str, base_url: &str) -> String {
184    html::convert_relative_urls(html, base_url)
185}
186
187/// Convert HTML content to UTF-8 encoding
188///
189/// Detects the current encoding from meta tags and converts to UTF-8 if needed.
190///
191/// # Arguments
192///
193/// * `html` - The HTML content to convert
194///
195/// # Returns
196///
197/// The UTF-8 encoded HTML content
198#[must_use]
199pub fn convert_to_utf8(html: &str) -> String {
200    html::convert_to_utf8(html)
201}
202
203/// Options for enhanced HTML-to-Markdown conversion.
204#[allow(clippy::struct_excessive_bools)]
205#[derive(Debug, Clone)]
206pub struct EnhancedOptions {
207    /// Extract LaTeX formulas from img.formula, `KaTeX`, `MathJax` elements.
208    pub extract_latex: bool,
209    /// Extract article metadata (author, date, hubs, tags).
210    pub extract_metadata: bool,
211    /// Apply post-processing (unicode normalization, LaTeX spacing, etc.).
212    pub post_process: bool,
213    /// Detect and correct code block languages.
214    pub detect_code_language: bool,
215    /// CSS selector used to scope Markdown conversion.
216    pub content_selector: Option<String>,
217    /// CSS selector for article body Markdown; prepends the selected article title when available.
218    pub body_selector: Option<String>,
219}
220
221impl Default for EnhancedOptions {
222    fn default() -> Self {
223        Self {
224            extract_latex: true,
225            extract_metadata: true,
226            post_process: true,
227            detect_code_language: true,
228            content_selector: None,
229            body_selector: None,
230        }
231    }
232}
233
234/// Result of enhanced HTML-to-Markdown conversion.
235#[derive(Debug, Clone)]
236pub struct EnhancedMarkdownResult {
237    pub markdown: String,
238    pub metadata: Option<metadata::ArticleMetadata>,
239}
240
241/// Convert HTML to Markdown with enhanced options.
242///
243/// Supports LaTeX formula extraction, metadata extraction, and
244/// post-processing pipeline matching the JavaScript implementation.
245///
246/// # Arguments
247///
248/// * `html` - The HTML content to convert
249/// * `base_url` - Optional base URL for resolving relative URLs
250/// * `options` - Enhanced conversion options
251///
252/// # Returns
253///
254/// Enhanced result with markdown text and optional metadata
255///
256/// # Errors
257///
258/// Returns an error if base conversion fails
259pub fn convert_html_to_markdown_enhanced(
260    html: &str,
261    base_url: Option<&str>,
262    options: &EnhancedOptions,
263) -> Result<EnhancedMarkdownResult> {
264    let mut html_for_markdown = scope_html_with_selectors(html, options);
265
266    if options.extract_latex {
267        html_for_markdown = replace_latex_formula_elements(&html_for_markdown);
268    }
269
270    if options.detect_code_language {
271        html_for_markdown = correct_code_languages(&html_for_markdown);
272    }
273
274    // Start with basic markdown conversion
275    let mut md = markdown::convert_html_to_markdown(&html_for_markdown, base_url)?;
276
277    // Extract metadata if requested
278    let extracted_metadata = if options.extract_metadata {
279        let meta = metadata::extract_metadata(html);
280        // Prepend metadata block
281        let header_lines = metadata::format_metadata_block(&meta);
282        if !header_lines.is_empty() {
283            let header = header_lines.join("\n");
284            // Insert after the first heading
285            if let Some(pos) = md.find("\n\n") {
286                md = format!("{}\n\n{}\n{}", &md[..pos], header, &md[pos + 2..]);
287            } else {
288                md = format!("{header}\n\n{md}");
289            }
290        }
291        // Append footer block
292        let footer_lines = metadata::format_footer_block(&meta);
293        if !footer_lines.is_empty() {
294            md.push_str("\n\n");
295            md.push_str(&footer_lines.join("\n"));
296        }
297        Some(meta)
298    } else {
299        None
300    };
301
302    // Apply post-processing if requested
303    if options.post_process {
304        md = postprocess::post_process_markdown(&md, &postprocess::PostProcessOptions::default());
305    }
306
307    if options.extract_latex {
308        md = normalize_extracted_latex_markdown(&md);
309    }
310
311    Ok(EnhancedMarkdownResult {
312        markdown: md,
313        metadata: extracted_metadata,
314    })
315}
316
317/// Convert HTML to Markdown using the kreuzberg html-to-markdown library.
318///
319/// Returns a structured result with content, metadata, tables, images, and warnings.
320/// This is a high-performance alternative to `convert_html_to_markdown` using the
321/// same Rust core that powers the kreuzberg ecosystem.
322///
323/// # Arguments
324///
325/// * `html` - The HTML content to convert
326/// * `base_url` - Optional base URL for converting relative URLs to absolute
327///
328/// # Returns
329///
330/// A `KreuzbergResult` with structured conversion output
331///
332/// # Errors
333///
334/// Returns an error if conversion fails
335pub fn convert_with_kreuzberg(
336    html: &str,
337    base_url: Option<&str>,
338) -> Result<kreuzberg::KreuzbergResult> {
339    kreuzberg::convert_with_kreuzberg(html, base_url)
340}
341
342/// Convert HTML to Markdown using kreuzberg after applying enhanced scoping options.
343///
344/// This keeps the alternate converter compatible with the same `contentSelector`
345/// and `bodySelector` controls used by the default enhanced converter.
346///
347/// # Errors
348///
349/// Returns an error if conversion fails.
350pub fn convert_with_kreuzberg_enhanced(
351    html: &str,
352    base_url: Option<&str>,
353    options: &EnhancedOptions,
354) -> Result<kreuzberg::KreuzbergResult> {
355    let scoped_html = scope_html_with_selectors(html, options);
356    kreuzberg::convert_with_kreuzberg(&scoped_html, base_url)
357}
358
359fn normalize_extracted_latex_markdown(markdown: &str) -> String {
360    let re = regex::Regex::new(r"\$([^$\n]+)\$").expect("valid regex");
361    re.replace_all(markdown, |caps: &regex::Captures<'_>| {
362        let formula = caps.get(1).map_or("", |m| m.as_str()).replace(r"\\", r"\");
363        format!("${formula}$")
364    })
365    .into_owned()
366}
367
368fn scope_html_with_selectors(html: &str, options: &EnhancedOptions) -> String {
369    if let Some(body_selector) = options.body_selector.as_deref() {
370        let body_html = markdown::select_html(html, body_selector);
371        let title_selector = options
372            .content_selector
373            .as_deref()
374            .map_or_else(|| "h1".to_string(), |selector| format!("{selector} h1, h1"));
375        let title_html = markdown::select_html(html, &title_selector);
376        return match (title_html, body_html) {
377            (Some(title), Some(body)) => format!("{title}\n{body}"),
378            (None, Some(body)) => body,
379            _ => html.to_string(),
380        };
381    }
382
383    options
384        .content_selector
385        .as_deref()
386        .and_then(|selector| markdown::select_html(html, selector))
387        .unwrap_or_else(|| html.to_string())
388}
389
390fn replace_latex_formula_elements(html: &str) -> String {
391    let mut result = html.to_string();
392
393    let img_formula_re = regex::Regex::new(r"(?is)<img\b[^>]*>").expect("valid regex");
394    result = img_formula_re
395        .replace_all(&result, |caps: &regex::Captures<'_>| {
396            let tag = caps.get(0).map_or("", |m| m.as_str());
397            if is_formula_img_tag(tag) {
398                extract_attr(tag, "source")
399                    .or_else(|| extract_attr(tag, "alt"))
400                    .map_or_else(
401                        || tag.to_string(),
402                        |latex| format!("${}$", normalize_latex_for_html(&latex)),
403                    )
404            } else {
405                tag.to_string()
406            }
407        })
408        .into_owned();
409
410    let math_attr_re = regex::Regex::new(
411        r"(?is)<(?P<tag>mjx-container|span|div)\b(?P<attrs>[^>]*)>.*?</(?P<tag_close>mjx-container|span|div)>",
412    )
413    .expect("valid regex");
414    math_attr_re
415        .replace_all(&result, |caps: &regex::Captures<'_>| {
416            let full = caps.get(0).map_or("", |m| m.as_str());
417            let attrs = caps.name("attrs").map_or("", |m| m.as_str());
418            let tag = caps
419                .name("tag")
420                .map_or("", |m| m.as_str())
421                .to_ascii_lowercase();
422            let tag_close = caps
423                .name("tag_close")
424                .map_or("", |m| m.as_str())
425                .to_ascii_lowercase();
426
427            if tag != tag_close || !is_math_attrs(&tag, attrs) {
428                return full.to_string();
429            }
430
431            extract_attr(attrs, "data-tex")
432                .or_else(|| extract_attr(attrs, "data-latex"))
433                .or_else(|| extract_annotation_tex(full))
434                .map_or_else(
435                    || full.to_string(),
436                    |latex| format!("${}$", normalize_latex_for_html(&latex)),
437                )
438        })
439        .into_owned()
440}
441
442fn correct_code_languages(html: &str) -> String {
443    let code_re = regex::Regex::new(r"(?is)<code\b(?P<attrs>[^>]*)>(?P<body>.*?)</code>")
444        .expect("valid regex");
445
446    code_re
447        .replace_all(html, |caps: &regex::Captures<'_>| {
448            let full = caps.get(0).map_or("", |m| m.as_str());
449            let attrs = caps.name("attrs").map_or("", |m| m.as_str());
450            let body = caps.name("body").map_or("", |m| m.as_str());
451
452            if !has_matlab_language(attrs) || !looks_like_coq(body) {
453                return full.to_string();
454            }
455
456            let updated_attrs = attrs
457                .replace("language-matlab", "language-coq")
458                .replace(r#"class="matlab""#, r#"class="coq""#)
459                .replace("class='matlab'", "class='coq'");
460
461            format!("<code{updated_attrs}>{body}</code>")
462        })
463        .into_owned()
464}
465
466fn is_formula_img_tag(tag: &str) -> bool {
467    extract_attr(tag, "source").is_some()
468        || extract_attr(tag, "class").is_some_and(|classes| classes.contains("formula"))
469}
470
471fn is_math_attrs(tag: &str, attrs: &str) -> bool {
472    tag == "mjx-container"
473        || extract_attr(attrs, "class").is_some_and(|classes| {
474            classes.contains("katex") || classes.contains("math") || classes.contains("MathJax")
475        })
476}
477
478fn has_matlab_language(attrs: &str) -> bool {
479    extract_attr(attrs, "class").is_some_and(|classes| {
480        classes
481            .split_whitespace()
482            .any(|class| class == "language-matlab" || class == "matlab")
483    })
484}
485
486fn looks_like_coq(text: &str) -> bool {
487    let decoded = crate::html::decode_html_entities(text);
488    [
489        "Require Import",
490        "Definition",
491        "Fixpoint",
492        "Lemma",
493        "Theorem",
494        "Proof",
495        "Qed",
496        "Notation",
497        "Inductive",
498    ]
499    .iter()
500    .any(|needle| decoded.contains(needle))
501}
502
503fn normalize_latex_for_html(latex: &str) -> String {
504    latex.trim().replace('\\', "&#92;")
505}
506
507fn extract_annotation_tex(html: &str) -> Option<String> {
508    let re = regex::Regex::new(
509        r#"(?is)<annotation\b[^>]*encoding\s*=\s*["']application/x-tex["'][^>]*>(.*?)</annotation>"#,
510    )
511    .ok()?;
512
513    re.captures(html).and_then(|caps| {
514        let text = caps.get(1)?.as_str().trim();
515        (!text.is_empty()).then(|| crate::html::decode_html_entities(text))
516    })
517}
518
519fn extract_attr(tag: &str, attr: &str) -> Option<String> {
520    let re = regex::Regex::new(&format!(
521        r#"(?is)\b{}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))"#,
522        regex::escape(attr)
523    ))
524    .ok()?;
525
526    re.captures(tag).and_then(|caps| {
527        let value = caps
528            .get(1)
529            .or_else(|| caps.get(2))
530            .or_else(|| caps.get(3))?
531            .as_str()
532            .trim();
533        (!value.is_empty()).then(|| crate::html::decode_html_entities(value))
534    })
535}
536
537// Re-export commonly used types
538pub use browser::BrowserEngine;
539pub use search::{
540    search, SearchDiagnostics, SearchResult, SearchResultItem, DEFAULT_LIMIT, DEFAULT_PROVIDER,
541    SEARCH_PROVIDERS,
542};