#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::multiple_crate_versions,
clippy::missing_errors_doc,
clippy::missing_panics_doc
)]
use eyre::Result;
use lol_html::{RewriteStrSettings, element, rewrite_str, text};
use serde_json::Value;
use std::sync::{Arc, Mutex};
use tracing::{debug, info, instrument};
pub mod constants;
pub mod elements;
pub mod error;
pub mod extractor;
pub mod extractors;
pub mod html_to_text;
pub mod metadata;
pub mod scoring;
pub mod standardize;
pub mod types;
pub mod utils;
#[cfg(target_arch = "wasm32")]
pub mod wasm;
use crate::extractor::{ExtractorRegistry, GenericExtractor};
use crate::metadata::MetadataExtractor;
pub use crate::types::{MetaTagItem, TrekOptions, TrekResponse};
#[derive(Debug)]
pub struct Trek {
options: TrekOptions,
extractor_registry: ExtractorRegistry,
}
impl Trek {
#[instrument(skip(options))]
pub fn new(options: TrekOptions) -> Self {
let mut extractor_registry = ExtractorRegistry::new();
extractor_registry.register(Box::new(GenericExtractor));
Self {
options,
extractor_registry,
}
}
#[instrument(skip(self, html))]
pub fn parse(&self, html: &str) -> Result<TrekResponse> {
let start_time = utils::current_time_ms();
let collected_data = self.collect_initial_data(html)?;
let metadata = MetadataExtractor::extract_from_collected_data(
&collected_data,
self.options.url.as_deref(),
);
let url = self.options.url.as_deref().unwrap_or("");
if let Some(extractor) = self
.extractor_registry
.find_extractor_from_data(url, &collected_data.schema_org_data)
{
info!("Using site-specific extractor: {}", extractor.name());
let extracted = extractor.extract_from_html(html)?;
#[allow(clippy::redundant_clone)]
let mut final_metadata = metadata.clone();
if let Some(title) = extracted.title {
final_metadata.title = title;
}
if let Some(author) = extracted.author {
final_metadata.author = author;
}
if let Some(published) = extracted.published {
final_metadata.published = published;
}
let content = extracted.content_html.unwrap_or_default();
final_metadata.word_count = utils::count_words(&content);
final_metadata.parse_time = utils::current_time_ms() - start_time;
return Ok(TrekResponse {
content,
content_markdown: None,
extractor_type: Some(extractor.name().to_string()),
meta_tags: collected_data.meta_tags.clone(),
metadata: final_metadata,
});
}
let result = self.parse_internal(html, &metadata, &collected_data.meta_tags, start_time)?;
if result.metadata.word_count < 200
&& (self.options.removal.remove_exact_selectors
|| self.options.removal.remove_partial_selectors)
{
info!(
"Initial parse returned very little content, trying again without clutter removal"
);
let mut retry_options = self.options.clone();
retry_options.removal.remove_exact_selectors = false;
retry_options.removal.remove_partial_selectors = false;
let retry_trek = Self::new(retry_options);
let retry_metadata = MetadataExtractor::extract_from_collected_data(
&collected_data,
self.options.url.as_deref(),
);
if let Ok(retry_result) = retry_trek.parse_internal(
html,
&retry_metadata,
&collected_data.meta_tags,
start_time,
) {
if retry_result.metadata.word_count > result.metadata.word_count {
debug!("Retry produced more content");
return Ok(retry_result);
}
}
}
Ok(result)
}
fn parse_internal(
&self,
html: &str,
metadata: &types::TrekMetadata,
meta_tags: &[MetaTagItem],
start_time: u64,
) -> Result<TrekResponse> {
let main_content = self.extract_main_content(html);
let body_content = self.extract_body_content(&main_content);
let cleaned_content = if self.options.removal.remove_exact_selectors
|| self.options.removal.remove_partial_selectors
{
let result = self.remove_clutter(&body_content)?;
if self.options.debug {
debug!("After clutter removal, content length: {}", result.len());
}
result
} else {
body_content
};
let final_content =
standardize::standardize_content(&cleaned_content, &metadata.title, self.options.debug);
let mut final_metadata = metadata.clone();
final_metadata.word_count = utils::count_words(&final_content);
final_metadata.parse_time = utils::current_time_ms() - start_time;
if final_metadata.image.is_empty() {
if let Some(first_image) = Self::extract_first_image_from_content(&final_content) {
debug!("Found first image in content: {}", first_image);
final_metadata.image = first_image;
}
}
Ok(TrekResponse {
content: final_content,
content_markdown: None,
extractor_type: None,
meta_tags: meta_tags.to_vec(),
metadata: final_metadata,
})
}
#[allow(clippy::disallowed_methods, clippy::unused_self)] fn collect_initial_data(&self, html: &str) -> Result<CollectedData> {
let collected_data = Arc::new(Mutex::new(CollectedData::default()));
let data_clone = Arc::clone(&collected_data);
let data_clone2 = Arc::clone(&collected_data);
let script_content = Arc::new(Mutex::new(String::new()));
let script_clone = Arc::clone(&script_content);
let title_content = Arc::new(Mutex::new(String::new()));
let title_clone = Arc::clone(&title_content);
let data_clone3 = Arc::clone(&collected_data);
let data_clone4 = Arc::clone(&collected_data);
let settings = RewriteStrSettings {
element_content_handlers: vec![
element!("meta[name], meta[property]", move |el| {
if let Some(content) = el.get_attribute("content") {
let mut data = data_clone.lock().expect("Failed to acquire lock");
let decoded_content = utils::decode_html_entities(&content);
if el.get_attribute("name").as_deref() == Some("fc:frame") {
data.mini_app_embed = Some(decoded_content.clone());
}
data.meta_tags.push(MetaTagItem {
name: el.get_attribute("name"),
property: el.get_attribute("property"),
content: decoded_content,
});
}
Ok(())
}),
element!("link[rel~=icon], link[rel~=shortcut]", move |el| {
if let Some(href) = el.get_attribute("href") {
let mut data = data_clone4.lock().expect("Failed to acquire lock");
if data.favicon.is_none()
|| el.get_attribute("rel").as_deref() == Some("icon")
{
data.favicon = Some(href);
}
}
Ok(())
}),
element!("title", move |_el| {
{
let mut content = title_clone.lock().expect("Failed to acquire lock");
content.clear();
}
Ok(())
}),
text!("title", move |t| {
{
let mut content = title_content.lock().expect("Failed to acquire lock");
content.push_str(t.as_str());
if t.last_in_text_node() {
let title_str = content.trim().to_string();
drop(content); let mut data = data_clone3.lock().expect("Failed to acquire lock");
data.title = Some(title_str);
}
}
Ok(())
}),
element!(r#"script[type="application/ld+json"]"#, move |_el| {
{
let mut content = script_clone.lock().expect("Failed to acquire lock");
content.clear();
}
Ok(())
}),
text!(r#"script[type="application/ld+json"]"#, move |t| {
{
let mut content = script_content.lock().expect("Failed to acquire lock");
content.push_str(t.as_str());
if t.last_in_text_node() {
if let Ok(json_data) = serde_json::from_str::<Value>(&content) {
drop(content); let mut data = data_clone2.lock().expect("Failed to acquire lock");
if let Some(graph) =
json_data.get("@graph").and_then(Value::as_array)
{
data.schema_org_data.extend(graph.clone());
} else {
data.schema_org_data.push(json_data);
}
}
}
}
Ok(())
}),
],
..RewriteStrSettings::default()
};
rewrite_str(html, settings)?;
let data = Arc::try_unwrap(collected_data).map_or_else(
|arc| arc.lock().expect("Failed to acquire lock").clone(),
|mutex| mutex.into_inner().expect("Failed to get inner value"),
);
Ok(data)
}
#[allow(clippy::unused_self, clippy::disallowed_methods)] fn extract_main_content(&self, html: &str) -> String {
html.to_string()
}
#[allow(clippy::unused_self)]
fn extract_body_content(&self, html: &str) -> String {
if let Some(body_start) = html.find("<body") {
if let Some(tag_end) = html[body_start..].find('>') {
let content_start = body_start + tag_end + 1;
if let Some(body_end) = html.rfind("</body>") {
let content = html[content_start..body_end].trim();
return content.trim_start_matches('\n').to_string();
}
}
}
html.trim_start_matches('\n').to_string()
}
#[allow(clippy::disallowed_methods)] fn extract_first_image_from_content(html: &str) -> Option<String> {
use lol_html::{RewriteStrSettings, element, rewrite_str};
let first_image = Arc::new(Mutex::new(None::<String>));
let image_clone = Arc::clone(&first_image);
let settings = RewriteStrSettings {
element_content_handlers: vec![element!("img", move |el| {
let mut image_guard = image_clone.lock().expect("Failed to acquire lock");
if image_guard.is_some() {
return Ok(());
}
if let Some(src) = el.get_attribute("src") {
if !src.starts_with("data:") && !src.is_empty() {
let width = el
.get_attribute("width")
.and_then(|w| w.parse::<u32>().ok())
.unwrap_or(100);
let height = el
.get_attribute("height")
.and_then(|h| h.parse::<u32>().ok())
.unwrap_or(100);
if width >= 50 && height >= 50 {
*image_guard = Some(src);
}
}
}
drop(image_guard);
Ok(())
})],
..RewriteStrSettings::default()
};
let _ = rewrite_str(html, settings).ok()?;
match Arc::try_unwrap(first_image) {
Ok(mutex) => mutex.into_inner().expect("Failed to get inner value"),
Err(arc) => {
let guard = arc.lock().expect("Failed to acquire lock");
guard.clone()
}
}
}
#[allow(clippy::unused_self, clippy::disallowed_methods)] fn remove_clutter(&self, html: &str) -> Result<String> {
use crate::constants::{PARTIAL_SELECTORS, TEST_ATTRIBUTES};
use lol_html::html_content::ContentType;
let remove_exact = self.options.removal.remove_exact_selectors;
let remove_partial = self.options.removal.remove_partial_selectors;
let settings = RewriteStrSettings {
element_content_handlers: vec![
element!(
"script, style, nav, footer, header, aside, noscript",
move |el| {
if remove_exact {
el.before("<!--REMOVE-->", ContentType::Html);
el.after("<!--/REMOVE-->", ContentType::Html);
el.remove();
}
Ok(())
}
),
element!(
"div, section, article, main, span, p, ul, ol, li, h1, h2, h3, h4, h5, h6",
move |el| {
let mut should_remove = false;
if remove_exact {
if let Some(class_attr) = el.get_attribute("class") {
for class in class_attr.split_whitespace() {
if class == "navigation" || class == "sidebar" {
should_remove = true;
break;
}
}
}
}
if !should_remove && remove_partial {
for attr in TEST_ATTRIBUTES {
if let Some(value) = el.get_attribute(attr) {
let value_lower = value.to_lowercase();
for pattern in PARTIAL_SELECTORS {
if value_lower.contains(pattern) {
should_remove = true;
break;
}
}
}
if should_remove {
break;
}
}
}
if should_remove {
el.before("<!--REMOVE-->", ContentType::Html);
el.after("<!--/REMOVE-->", ContentType::Html);
el.remove();
}
Ok(())
}
),
],
..RewriteStrSettings::default()
};
let result = rewrite_str(html, settings)?;
let remove_pattern = regex::Regex::new(r"(?s)<!--REMOVE-->.*?<!--/REMOVE-->").unwrap();
let cleaned = remove_pattern.replace_all(&result, "").to_string();
Ok(cleaned)
}
}
#[derive(Debug, Clone, Default)]
pub struct CollectedData {
pub meta_tags: Vec<MetaTagItem>,
pub schema_org_data: Vec<Value>,
pub title: Option<String>,
pub favicon: Option<String>,
pub mini_app_embed: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let options = TrekOptions::default();
let _trek = Trek::new(options);
}
#[test]
fn test_fallback_image_extraction() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Test Article</title>
<meta name="description" content="Test description">
</head>
<body>
<article>
<h1>Article Title</h1>
<img src="/tracking.gif" width="1" height="1" alt="">
<p>Some text here</p>
<img src="https://example.com/main-image.jpg" width="800" height="600" alt="Main article image">
<p>More content</p>
<img src="https://example.com/another-image.jpg" alt="Another image">
</article>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
assert_eq!(result.metadata.image, "https://example.com/main-image.jpg");
}
#[test]
fn test_no_fallback_when_og_image_exists() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Test Article</title>
<meta property="og:image" content="https://example.com/og-image.jpg">
</head>
<body>
<article>
<h1>Article Title</h1>
<img src="https://example.com/content-image.jpg" width="800" height="600" alt="Content image">
</article>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
assert_eq!(result.metadata.image, "https://example.com/og-image.jpg");
}
#[test]
fn test_no_suitable_images() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<!DOCTYPE html>
<html>
<head>
<title>Test Article</title>
</head>
<body>
<article>
<h1>Article Title</h1>
<img src="/tracking.gif" width="1" height="1" alt="">
<img src="/icon.png" width="16" height="16" alt="Icon">
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="">
<p>Content without suitable images</p>
</article>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
assert_eq!(result.metadata.image, "");
}
#[test]
#[allow(clippy::disallowed_methods)] fn test_basic_extraction() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<html>
<head>
<title>Test Page</title>
<meta name="description" content="A test page">
</head>
<body>
<article>
<h1>Main Title</h1>
<p>This is a test paragraph with some content.</p>
</article>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
assert!(result.metadata.word_count > 0);
assert_eq!(result.metadata.title, "Test Page");
assert_eq!(result.metadata.description, "A test page");
}
#[test]
#[allow(clippy::disallowed_methods)]
fn test_debug_extraction() {
let trek = Trek::new(TrekOptions {
debug: true,
..Default::default()
});
let html = r#"
<html>
<body>
<main>
<h1>Main Content</h1>
<p>First paragraph here.</p>
<p>Second paragraph here.</p>
</main>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
println!("Debug - Content: {}", result.content);
println!("Debug - Word count: {}", result.metadata.word_count);
assert!(!result.content.is_empty(), "Should have content");
assert!(result.metadata.word_count > 0, "Should count words");
}
#[test]
#[allow(clippy::disallowed_methods)]
fn test_remove_clutter() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<html>
<body>
<nav>Navigation</nav>
<article>Content</article>
<footer>Footer</footer>
</body>
</html>
"#;
let result = trek.remove_clutter(html).unwrap();
println!("After clutter removal: {result}");
assert!(!result.contains("<nav>"), "Should remove nav");
assert!(!result.contains("<footer>"), "Should remove footer");
assert!(result.contains("<article>"), "Should keep article");
}
#[test]
#[allow(clippy::disallowed_methods)]
fn test_html_tags_preserved_in_extraction() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<html>
<head>
<title>Test Article</title>
</head>
<body>
<article>
<h1>Main Title</h1>
<p>This article references <a href="https://example.com">an important source</a> for context.</p>
<p>You can also check <a href="https://test.com">this link</a> and <a href="https://another.com">another link</a> for more info.</p>
<p>This text is <strong>very important</strong> and <em>emphasized</em>.</p>
</article>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
println!("Extracted content: {:?}", result.content);
assert!(
result.content.contains("<a href="),
"Should preserve anchor tags"
);
assert!(
result.content.contains("<strong>"),
"Should preserve strong tags"
);
assert!(result.content.contains("<em>"), "Should preserve em tags");
assert!(
result.content.contains("</a>"),
"Should preserve closing anchor tags"
);
assert!(
result.content.contains("</strong>"),
"Should preserve closing strong tags"
);
assert!(
result.content.contains("</em>"),
"Should preserve closing em tags"
);
assert!(
result.content.contains("an important source"),
"Should preserve link text"
);
assert!(
result.content.contains("very important"),
"Should preserve strong text"
);
assert!(
result.content.contains("emphasized"),
"Should preserve em text"
);
}
#[test]
#[allow(clippy::disallowed_methods)]
fn test_whitespace_handling_in_extraction() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<html>
<head>
<title>Test Article</title>
</head>
<body>
<article>
<h1>Title with excessive spaces</h1>
<p>This paragraph has multiple spaces between words.</p>
<p>
This paragraph has
line breaks and multiple
spaces throughout.
</p>
<p>Normal paragraph.</p>
</article>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
println!("Whitespace test result:\n{}", result.content);
assert!(
!result.content.contains(" "),
"Should not have triple spaces"
);
assert!(
!result.content.contains(" "),
"Should not have double spaces"
);
assert!(result.content.contains("<p>"), "Should have paragraph tags");
assert!(
result.content.contains("</p>"),
"Should have closing paragraph tags"
);
assert!(
result.content.contains("Title with excessive spaces"),
"Title should be normalized"
);
assert!(
result
.content
.contains("This paragraph has multiple spaces between words"),
"First paragraph should be normalized"
);
}
#[test]
#[allow(clippy::disallowed_methods)]
fn test_div_flattening_reduces_newlines() {
let trek = Trek::new(TrekOptions::default());
let html = r#"
<html>
<head>
<title>Test Article</title>
</head>
<body>
<div>
<div>
<div>
<h1>How A.I. Sees Us</h1>
</div>
</div>
<div>
<div>
<p>Not only can A.I. now make these assessments with remarkable accuracy.</p>
</div>
</div>
</div>
</body>
</html>
"#;
let result = trek.parse(html).unwrap();
println!("Div flattening result:\n{}", result.content);
assert!(
!result.content.starts_with("\n\n\n"),
"Should not start with multiple newlines"
);
let div_count = result.content.matches("<div").count();
assert!(
div_count == 0,
"All wrapper divs should be flattened, found {div_count} divs"
);
assert!(result.content.contains("<h1>How A.I. Sees Us</h1>"));
assert!(
result
.content
.contains("<p>Not only can A.I. now make these assessments")
);
}
}