browsing 0.1.6

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Markdown extraction from DOM — convert page content to clean Markdown

use crate::error::Result;

/// Extract Markdown from raw HTML
pub fn extract_markdown(html: &str) -> Result<String> {
    let mut md = String::new();
    let mut in_tag = false;
    let mut tag_name = String::new();
    let mut in_script = false;
    let mut in_style = false;
    let mut pending_prefix: Option<String> = None; // Prefix to add before next text

    let chars: Vec<char> = html.chars().collect();
    let mut i = 0;

    while i < chars.len() {
        let c = chars[i];

        if c == '<' {
            in_tag = true;
            tag_name.clear();
            i += 1;
            continue;
        }

        if c == '>' && in_tag {
            in_tag = false;
            let tag = tag_name.trim().to_lowercase();

            // Track script/style state
            if tag == "script" || tag.starts_with("script ") {
                in_script = true;
            } else if tag == "/script" {
                in_script = false;
            } else if tag == "style" || tag.starts_with("style ") {
                in_style = true;
            } else if tag == "/style" {
                in_style = false;
            }

            // Opening block tags: set pending prefix
            if tag == "h1" || tag.starts_with("h1 ") {
                pending_prefix = Some("\n# ".to_string());
            } else if tag == "h2" || tag.starts_with("h2 ") {
                pending_prefix = Some("\n## ".to_string());
            } else if tag == "h3" || tag.starts_with("h3 ") {
                pending_prefix = Some("\n### ".to_string());
            } else if tag == "h4" || tag.starts_with("h4 ") {
                pending_prefix = Some("\n#### ".to_string());
            } else if tag == "p" || tag.starts_with("p ") {
                pending_prefix = Some("\n\n".to_string());
            } else if tag == "li" || tag.starts_with("li ") {
                pending_prefix = Some("\n- ".to_string());
            } else if tag == "blockquote" || tag.starts_with("blockquote ") {
                pending_prefix = Some("\n> ".to_string());
            } else if tag == "pre" || tag.starts_with("pre ") {
                pending_prefix = Some("\n```\n".to_string());
            } else if tag == "strong" || tag == "b" {
                pending_prefix = Some("**".to_string());
            } else if tag == "em" || tag == "i" {
                pending_prefix = Some("*".to_string());
            } else if tag == "code" && !in_pre(&md) {
                pending_prefix = Some("`".to_string());
            } else if tag.starts_with("a ") {
                if let Some(href) = extract_attr(&tag, "href") {
                    pending_prefix = Some(format!("[{}](", href));
                }
            }

            // Closing / inline tags
            if tag == "br" || tag == "br/" || tag.starts_with("br ") {
                md.push('\n');
            } else if tag == "hr" || tag.starts_with("hr ") {
                md.push_str("\n---\n");
            } else if tag == "/pre" || tag.starts_with("pre ") {
                md.push_str("\n```\n");
            } else if tag == "/code" && !in_pre(&md) {
                md.push('`');
            } else if tag == "/a" || tag.starts_with("a ") {
                md.push(')');
            } else if tag == "/strong" || tag == "/b" {
                md.push_str("**");
            } else if tag == "/em" || tag == "/i" {
                md.push('*');
            } else if tag == "/ul" || tag == "/ol" {
                md.push('\n');
            } else if tag == "/li" || tag.starts_with("li ") {
                md.push('\n');
            } else if tag == "/blockquote" || tag.starts_with("blockquote ") {
                md.push('\n');
            }

            i += 1;
            continue;
        }

        if in_tag {
            if c.is_whitespace() && tag_name.is_empty() {
                // Skip leading whitespace in tag
            } else {
                tag_name.push(c);
            }
            i += 1;
            continue;
        }

        // Skip content inside script/style
        if in_script || in_style {
            i += 1;
            continue;
        }

        // Add visible text
        if !in_tag {
            // Apply pending prefix before text
            if let Some(prefix) = pending_prefix.take() {
                md.push_str(&prefix);
            }

            // Collapse whitespace
            if c.is_whitespace() {
                if md.ends_with(' ') || md.ends_with('\n') {
                    // Skip consecutive whitespace
                } else {
                    md.push(' ');
                }
            } else {
                md.push(c);
            }
        }

        i += 1;
    }

    // Clean up
    let md = md.replace("  ", " ");
    let md = md.lines().map(|l| l.trim_end()).collect::<Vec<_>>().join("\n");
    let md = md.replace("\n\n\n", "\n\n");

    Ok(md.trim().to_string())
}

/// Check if we're currently inside a pre block
fn in_pre(md: &str) -> bool {
    let pre_count = md.matches("\n```\n").count();
    pre_count % 2 == 1
}

/// Extract an attribute value from a tag string
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
    let pattern = format!("{}=\"", attr);
    let start = tag.find(&pattern)? + pattern.len();
    let end = tag[start..].find('"')? + start;
    Some(tag[start..end].to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_markdown_basic() {
        let html = "<h1>Title</h1><p>Hello world</p>";
        let md = extract_markdown(html).unwrap();
        assert!(md.contains("# Title"));
        assert!(md.contains("Hello world"));
    }

    #[test]
    fn test_extract_markdown_headers() {
        let html = "<h2>Section</h2><h3>Subsection</h3>";
        let md = extract_markdown(html).unwrap();
        assert!(md.contains("## Section"));
        assert!(md.contains("### Subsection"));
    }

    #[test]
    fn test_extract_markdown_links() {
        let html = r#"<a href="https://example.com">Link text</a>"#;
        let md = extract_markdown(html).unwrap();
        assert!(md.contains("[https://example.com]("));
    }

    #[test]
    fn test_extract_markdown_skips_script() {
        let html = "<p>Hello</p><script>alert('x')</script><p>World</p>";
        let md = extract_markdown(html).unwrap();
        assert!(!md.contains("alert"));
        assert!(md.contains("Hello"));
        assert!(md.contains("World"));
    }

    #[test]
    fn test_extract_markdown_list() {
        let html = "<ul><li>Item 1</li><li>Item 2</li></ul>";
        let md = extract_markdown(html).unwrap();
        assert!(md.contains("- Item 1"));
        assert!(md.contains("- Item 2"));
    }

    #[test]
    fn test_extract_markdown_bold() {
        let html = "<p><strong>Bold</strong> and <b>also</b></p>";
        let md = extract_markdown(html).unwrap();
        assert!(md.contains("**Bold**"));
        assert!(md.contains("**also**"));
    }
}