use scraper::Html;
use std::time::Instant;
use url::Url;
use crate::content::{
extract_headings, extract_paragraphs, extract_lists,
extract_tables, extract_code_blocks, extract_quotes, extract_images,
};
use crate::links::extract_links;
use crate::metadata::{extract_metadata, extract_structured_data};
use crate::text::extract_text;
use crate::types::{
ParsedContent, PageMetadata, TextContent, Heading, Link, Image,
ListContent, TableContent, CodeBlock, Quote, StructuredData,
ParseStats, ParserConfig, ParserResult,
};
#[derive(Debug, Clone)]
pub struct HtmlParser {
config: ParserConfig,
}
impl HtmlParser {
pub fn new() -> Self {
Self {
config: ParserConfig::default(),
}
}
pub fn with_config(config: ParserConfig) -> Self {
Self { config }
}
pub fn with_base_url(url: &str) -> ParserResult<Self> {
let parsed_url = Url::parse(url)?;
Ok(Self {
config: ParserConfig {
base_url: Some(parsed_url),
..Default::default()
},
})
}
pub fn set_base_url(&mut self, url: &str) -> ParserResult<()> {
self.config.base_url = Some(Url::parse(url)?);
Ok(())
}
pub fn config(&self) -> &ParserConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut ParserConfig {
&mut self.config
}
pub fn parse(&self, html: &str) -> ParserResult<ParsedContent> {
let start = Instant::now();
let html_size = html.len();
let document = Html::parse_document(html);
let mut stats = ParseStats {
html_size,
..Default::default()
};
stats.node_count = document.tree.nodes().count();
let metadata = extract_metadata(&document, self.config.base_url.as_ref())?;
let text = extract_text(&document, &self.config)?;
let headings = extract_headings(&document)?;
let paragraphs = extract_paragraphs(&document, &self.config)?;
let links = if self.config.extract_links {
extract_links(&document, &self.config)?
} else {
Vec::new()
};
let images = if self.config.extract_images {
extract_images(&document, self.config.base_url.as_ref())?
} else {
Vec::new()
};
let lists = extract_lists(&document)?;
let tables = if self.config.extract_tables {
extract_tables(&document)?
} else {
Vec::new()
};
let code_blocks = if self.config.extract_code_blocks {
extract_code_blocks(&document)?
} else {
Vec::new()
};
let quotes = extract_quotes(&document)?;
let structured_data = if self.config.extract_structured_data {
extract_structured_data(&document)
} else {
Vec::new()
};
stats.parse_time_us = start.elapsed().as_micros() as u64;
Ok(ParsedContent {
metadata,
text,
headings,
paragraphs,
links,
images,
lists,
tables,
code_blocks,
quotes,
structured_data,
stats,
})
}
pub fn parse_fragment(&self, html: &str) -> ParserResult<ParsedContent> {
let start = Instant::now();
let wrapped = format!("<body>{}</body>", html);
let document = Html::parse_fragment(&wrapped);
let mut stats = ParseStats {
html_size: html.len(),
node_count: document.tree.nodes().count(),
..Default::default()
};
let text = extract_text(&document, &self.config)?;
let headings = extract_headings(&document)?;
let paragraphs = extract_paragraphs(&document, &self.config)?;
let links = extract_links(&document, &self.config)?;
let images = extract_images(&document, self.config.base_url.as_ref())?;
let lists = extract_lists(&document)?;
let tables = extract_tables(&document)?;
let code_blocks = extract_code_blocks(&document)?;
let quotes = extract_quotes(&document)?;
stats.parse_time_us = start.elapsed().as_micros() as u64;
Ok(ParsedContent {
metadata: PageMetadata::default(),
text,
headings,
paragraphs,
links,
images,
lists,
tables,
code_blocks,
quotes,
structured_data: Vec::new(),
stats,
})
}
pub fn extract_metadata(&self, html: &str) -> ParserResult<PageMetadata> {
let document = Html::parse_document(html);
extract_metadata(&document, self.config.base_url.as_ref())
}
pub fn extract_text(&self, html: &str) -> ParserResult<TextContent> {
let document = Html::parse_document(html);
extract_text(&document, &self.config)
}
pub fn extract_headings(&self, html: &str) -> ParserResult<Vec<Heading>> {
let document = Html::parse_document(html);
extract_headings(&document)
}
pub fn extract_links(&self, html: &str) -> ParserResult<Vec<Link>> {
let document = Html::parse_document(html);
extract_links(&document, &self.config)
}
pub fn extract_images(&self, html: &str) -> ParserResult<Vec<Image>> {
let document = Html::parse_document(html);
extract_images(&document, self.config.base_url.as_ref())
}
pub fn extract_lists(&self, html: &str) -> ParserResult<Vec<ListContent>> {
let document = Html::parse_document(html);
extract_lists(&document)
}
pub fn extract_tables(&self, html: &str) -> ParserResult<Vec<TableContent>> {
let document = Html::parse_document(html);
extract_tables(&document)
}
pub fn extract_code_blocks(&self, html: &str) -> ParserResult<Vec<CodeBlock>> {
let document = Html::parse_document(html);
extract_code_blocks(&document)
}
pub fn extract_quotes(&self, html: &str) -> ParserResult<Vec<Quote>> {
let document = Html::parse_document(html);
extract_quotes(&document)
}
pub fn extract_structured_data(&self, html: &str) -> Vec<StructuredData> {
let document = Html::parse_document(html);
extract_structured_data(&document)
}
pub fn resolve_url(&self, href: &str) -> Option<String> {
let trimmed = href.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return Some(trimmed.to_string());
}
if trimmed.starts_with("//") {
return Some(format!("https:{}", trimmed));
}
self.config.base_url.as_ref()
.and_then(|base| base.join(trimmed).ok())
.map(|u| u.to_string())
}
pub fn has_base_url(&self) -> bool {
self.config.base_url.is_some()
}
pub fn base_url(&self) -> Option<&Url> {
self.config.base_url.as_ref()
}
}
impl Default for HtmlParser {
fn default() -> Self {
Self::new()
}
}
pub fn parse(html: &str) -> ParserResult<ParsedContent> {
HtmlParser::new().parse(html)
}
pub fn parse_with_url(html: &str, base_url: &str) -> ParserResult<ParsedContent> {
HtmlParser::with_base_url(base_url)?.parse(html)
}
pub fn get_metadata(html: &str) -> ParserResult<PageMetadata> {
HtmlParser::new().extract_metadata(html)
}
pub fn get_text(html: &str) -> ParserResult<TextContent> {
HtmlParser::new().extract_text(html)
}
pub fn get_links(html: &str) -> ParserResult<Vec<Link>> {
HtmlParser::new().extract_links(html)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_HTML: &str = r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test Page</title>
<meta name="description" content="A test page for parsing">
<meta property="og:title" content="OG Test Page">
<link rel="canonical" href="https://example.com/test">
</head>
<body>
<header><nav>Navigation</nav></header>
<main>
<article>
<h1>Main Title</h1>
<p>This is the first paragraph of the article content.</p>
<h2>Section One</h2>
<p>Another paragraph with more detailed information.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
<a href="/internal">Internal Link</a>
<a href="https://external.com" rel="nofollow">External Link</a>
<img src="/image.jpg" alt="Test Image">
<pre><code class="language-rust">fn main() {}</code></pre>
</article>
</main>
<footer>Footer content</footer>
</body>
</html>
"#;
#[test]
fn test_parser_new() {
let parser = HtmlParser::new();
assert!(!parser.has_base_url());
}
#[test]
fn test_parser_with_base_url() {
let parser = HtmlParser::with_base_url("https://example.com").unwrap();
assert!(parser.has_base_url());
assert_eq!(parser.base_url().unwrap().host_str(), Some("example.com"));
}
#[test]
fn test_parser_set_base_url() {
let mut parser = HtmlParser::new();
parser.set_base_url("https://example.com").unwrap();
assert!(parser.has_base_url());
}
#[test]
fn test_full_parse() {
let parser = HtmlParser::with_base_url("https://example.com").unwrap();
let result = parser.parse(SAMPLE_HTML).unwrap();
assert_eq!(result.metadata.title, Some("Test Page".to_string()));
assert_eq!(result.metadata.description, Some("A test page for parsing".to_string()));
assert!(result.metadata.opengraph.is_present());
assert!(!result.headings.is_empty());
assert!(!result.paragraphs.is_empty());
assert!(!result.lists.is_empty());
assert!(!result.links.is_empty());
assert!(!result.images.is_empty());
assert!(!result.code_blocks.is_empty());
assert!(result.stats.html_size > 0);
assert!(result.stats.parse_time_us > 0);
}
#[test]
fn test_extract_metadata_only() {
let parser = HtmlParser::new();
let metadata = parser.extract_metadata(SAMPLE_HTML).unwrap();
assert_eq!(metadata.title, Some("Test Page".to_string()));
assert_eq!(metadata.language, Some("en".to_string()));
}
#[test]
fn test_extract_text_only() {
let parser = HtmlParser::new();
let text = parser.extract_text(SAMPLE_HTML).unwrap();
assert!(text.word_count > 0);
assert!(text.cleaned_text.contains("Main Title"));
}
#[test]
fn test_extract_links_only() {
let parser = HtmlParser::with_base_url("https://example.com").unwrap();
let links = parser.extract_links(SAMPLE_HTML).unwrap();
assert_eq!(links.len(), 2);
let internal = links.iter().find(|l| l.href == "/internal").unwrap();
assert_eq!(internal.url, Some("https://example.com/internal".to_string()));
let external = links.iter().find(|l| l.href == "https://external.com").unwrap();
assert!(external.is_nofollow);
}
#[test]
fn test_extract_images_only() {
let parser = HtmlParser::with_base_url("https://example.com").unwrap();
let images = parser.extract_images(SAMPLE_HTML).unwrap();
assert_eq!(images.len(), 1);
assert_eq!(images[0].alt, "Test Image");
assert_eq!(images[0].url, Some("https://example.com/image.jpg".to_string()));
}
#[test]
fn test_parse_fragment() {
let parser = HtmlParser::new();
let result = parser.parse_fragment("<p>Hello <strong>world</strong></p>").unwrap();
let _ = result.paragraphs; }
#[test]
fn test_resolve_url() {
let parser = HtmlParser::with_base_url("https://example.com/dir/").unwrap();
assert_eq!(
parser.resolve_url("page.html"),
Some("https://example.com/dir/page.html".to_string())
);
assert_eq!(
parser.resolve_url("/absolute"),
Some("https://example.com/absolute".to_string())
);
assert_eq!(
parser.resolve_url("https://other.com"),
Some("https://other.com".to_string())
);
}
#[test]
fn test_convenience_parse() {
let result = parse(SAMPLE_HTML).unwrap();
assert!(result.metadata.title.is_some());
}
#[test]
fn test_convenience_parse_with_url() {
let result = parse_with_url(SAMPLE_HTML, "https://example.com").unwrap();
assert!(result.metadata.title.is_some());
}
#[test]
fn test_convenience_get_metadata() {
let metadata = get_metadata(SAMPLE_HTML).unwrap();
assert_eq!(metadata.title, Some("Test Page".to_string()));
}
#[test]
fn test_convenience_get_text() {
let text = get_text(SAMPLE_HTML).unwrap();
assert!(text.word_count > 0);
}
#[test]
fn test_convenience_get_links() {
let links = get_links(SAMPLE_HTML).unwrap();
assert!(!links.is_empty());
}
#[test]
fn test_parser_with_minimal_config() {
let config = ParserConfig::minimal();
let parser = HtmlParser::with_config(config);
let result = parser.parse(SAMPLE_HTML).unwrap();
assert!(result.metadata.title.is_some());
assert!(result.text.word_count > 0);
assert!(result.images.is_empty());
assert!(result.tables.is_empty());
}
#[test]
fn test_malformed_html() {
let parser = HtmlParser::new();
let result = parser.parse("<p>Unclosed paragraph <div>Mixed</p></div>");
assert!(result.is_ok());
}
#[test]
fn test_empty_html() {
let parser = HtmlParser::new();
let result = parser.parse("").unwrap();
assert!(result.metadata.title.is_none());
assert!(result.text.is_empty());
}
#[test]
fn test_parser_default() {
let parser = HtmlParser::default();
assert!(!parser.has_base_url());
}
#[test]
fn test_config_mutation() {
let mut parser = HtmlParser::new();
parser.config_mut().extract_images = false;
let result = parser.parse(SAMPLE_HTML).unwrap();
assert!(result.images.is_empty());
}
}