use dom_smoothie::{Config, Readability, TextMode};
use scraper::{Html, Selector};
use skyscraper::{
html as xpath_html,
xpath::{
self, XpathItemTree,
grammar::{
NonTreeXpathNode, XpathItemTreeNodeData,
data_model::{Node, XpathItem},
},
},
};
use url::Url;
use crawlkit_core::error::{CrawlError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkSelectorType {
Css,
Xpath,
}
pub fn extract_links(html_content: &str, selector: &str) -> Vec<String> {
try_extract_links(html_content, selector).unwrap_or_default()
}
pub fn try_extract_links(html_content: &str, selector: &str) -> Result<Vec<String>> {
let document = Html::parse_document(html_content);
let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
selector: selector.to_string(),
message: e.to_string(),
})?;
let mut seen = std::collections::HashSet::new();
let mut links = Vec::new();
for element in document.select(&sel) {
if let Some(href) = element.value().attr("href")
&& seen.insert(href.to_string())
{
links.push(href.to_string());
}
}
Ok(links)
}
pub fn extract_links_by_selector(
html_content: &str,
selector: &str,
selector_type: LinkSelectorType,
) -> Result<Vec<String>> {
match selector_type {
LinkSelectorType::Css => try_extract_links(html_content, selector),
LinkSelectorType::Xpath => extract_links_by_xpath(html_content, selector),
}
}
pub fn extract_links_by_xpath(html_content: &str, selector: &str) -> Result<Vec<String>> {
let sanitized = sanitize_for_xpath(html_content);
let document = xpath_html::parse(&sanitized).map_err(|e| CrawlError::Html(e.to_string()))?;
let tree = XpathItemTree::from(&document);
let xpath_expr = xpath::parse(selector).map_err(|e| CrawlError::Selector {
selector: selector.to_string(),
message: e.to_string(),
})?;
let item_set = xpath_expr
.apply(&tree)
.map_err(|e| CrawlError::Html(e.to_string()))?;
let mut seen = std::collections::HashSet::new();
let mut links = Vec::new();
for item in &item_set {
let href = match item {
XpathItem::Node(Node::NonTreeNode(NonTreeXpathNode::AttributeNode(attribute))) => {
attribute.value.clone()
}
XpathItem::Node(Node::TreeNode(tree_node)) => match tree_node.data {
XpathItemTreeNodeData::ElementNode(element) => element
.get_attribute("href")
.map(str::to_string)
.unwrap_or_default(),
_ => String::new(),
},
_ => String::new(),
};
let href = href.trim();
if !href.is_empty() && seen.insert(href.to_string()) {
links.push(href.to_string());
}
}
Ok(links)
}
pub fn resolve_url(base_url: &str, relative: &str) -> Option<String> {
let relative = relative.trim();
if should_skip_link(relative) {
return None;
}
let base = Url::parse(base_url).ok()?;
let resolved = base.join(relative).ok()?;
match resolved.scheme() {
"http" | "https" => Some(resolved.to_string()),
_ => None,
}
}
pub fn extract_absolute_links(
html_content: &str,
selector: &str,
selector_type: LinkSelectorType,
base_url: &str,
) -> Result<Vec<String>> {
let links = extract_links_by_selector(html_content, selector, selector_type)?;
let mut seen = std::collections::HashSet::new();
let mut absolute_links = Vec::new();
for link in links {
if let Some(url) = resolve_url(base_url, &link)
&& seen.insert(url.clone())
{
absolute_links.push(url);
}
}
Ok(absolute_links)
}
fn should_skip_link(link: &str) -> bool {
link.is_empty()
|| link.starts_with('#')
|| link.starts_with("javascript:")
|| link.starts_with("mailto:")
|| link.starts_with("tel:")
|| link.starts_with("data:")
}
#[derive(Debug, Clone, Default)]
pub struct Article {
pub title: String,
pub content: String,
pub date: Option<String>,
pub author: Option<String>,
pub description: Option<String>,
}
pub fn extract_article(html_content: &str, base_url: &str) -> Article {
let cfg = Config {
text_mode: TextMode::Markdown,
..Default::default()
};
let url = if base_url.is_empty() { None } else { Some(base_url) };
let mut readability = match Readability::new(html_content, url, Some(cfg)) {
Ok(r) => r,
Err(_) => return Article::default(),
};
let dom_article = match readability.parse() {
Ok(a) => a,
Err(_) => return Article::default(),
};
let content = dom_article.text_content.to_string();
Article {
title: dom_article.title,
content,
date: dom_article.published_time,
author: dom_article.byline,
description: dom_article.excerpt,
}
}
pub fn extract_readable_content(raw_html: &str) -> Result<String> {
let cfg = Config {
text_mode: TextMode::Markdown,
..Default::default()
};
let mut readability = dom_smoothie::Readability::new(raw_html, None, Some(cfg))
.map_err(|e| CrawlError::Readability(e.to_string()))?;
let article = readability
.parse()
.map_err(|e| CrawlError::Readability(e.to_string()))?;
Ok(article.content.to_string())
}
pub fn extract_content_by_selector(raw_html: &str, content_selector: &str) -> Result<String> {
let document = Html::parse_document(raw_html);
let selector = Selector::parse(content_selector).map_err(|e| CrawlError::Selector {
selector: content_selector.to_string(),
message: e.to_string(),
})?;
let content = document
.select(&selector)
.next()
.map(|el| el.text().collect::<Vec<_>>().join("\n").trim().to_string())
.unwrap_or_default();
Ok(content)
}
pub fn extract_content(raw_html: &str, content_selector: &str) -> Result<String> {
match extract_readable_content(raw_html) {
Ok(content) if !content.is_empty() && content.len() > 100 => Ok(content),
_ => extract_content_by_selector(raw_html, content_selector),
}
}
pub fn extract_texts(raw_html: &str, selector: &str) -> Result<Vec<String>> {
let document = Html::parse_document(raw_html);
let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
selector: selector.to_string(),
message: e.to_string(),
})?;
let texts: Vec<String> = document
.select(&sel)
.filter_map(|el| {
let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
if text.is_empty() { None } else { Some(text) }
})
.collect();
Ok(texts)
}
pub fn extract_attributes(raw_html: &str, selector: &str, attr: &str) -> Result<Vec<String>> {
let document = Html::parse_document(raw_html);
let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
selector: selector.to_string(),
message: e.to_string(),
})?;
let values: Vec<String> = document
.select(&sel)
.filter_map(|el| el.value().attr(attr).map(ToString::to_string))
.filter(|v| !v.is_empty())
.collect();
Ok(values)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_html() -> &'static str {
r##"<!DOCTYPE html>
<html>
<body>
<a class="link" href="https://example.com/article/1">文章1</a>
<a class="link" href="/article/2">文章2</a>
<a class="link" href="//cdn.example.com/article/3">文章3</a>
<a class="link" href="#top">锚点</a>
<a class="link" href="javascript:void(0)">脚本</a>
<a class="link" href="mailto:test@example.com">邮箱</a>
</body>
</html>"##
}
#[test]
fn try_extract_links_returns_selector_error() {
let result = try_extract_links("<html></html>", "!!!invalid");
assert!(result.is_err());
}
#[test]
fn extract_absolute_links_by_css_filters_and_resolves() {
let links = extract_absolute_links(
sample_html(),
"a.link",
LinkSelectorType::Css,
"https://example.com/base/",
)
.unwrap();
assert_eq!(links.len(), 3);
assert!(links.contains(&"https://example.com/article/1".to_string()));
assert!(links.contains(&"https://example.com/article/2".to_string()));
assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
}
#[test]
fn extract_absolute_links_by_xpath_reads_href_attributes() {
let links = extract_absolute_links(
sample_html(),
"//a[@class='link']/@href",
LinkSelectorType::Xpath,
"https://example.com",
)
.unwrap();
assert_eq!(links.len(), 3);
assert!(links.contains(&"https://example.com/article/1".to_string()));
assert!(links.contains(&"https://example.com/article/2".to_string()));
assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
}
#[test]
fn resolve_url_skips_non_page_links() {
assert!(resolve_url("https://example.com", "javascript:void(0)").is_none());
assert!(resolve_url("https://example.com", "mailto:test@example.com").is_none());
assert!(resolve_url("https://example.com", "#top").is_none());
}
#[test]
fn test_sanitize_for_xpath_meta() {
let html = r#"<html><head><meta charset="utf-8"></head><body></body></html>"#;
let sanitized = sanitize_for_xpath(html);
assert!(sanitized.contains("meta"));
assert!(sanitized.contains("charset"));
}
#[test]
fn test_sanitize_for_xpath_self_closing_unchanged() {
let html = r#"<html><head><meta charset="utf-8" /></head><body></body></html>"#;
let sanitized = sanitize_for_xpath(html);
assert!(sanitized.contains("meta"));
assert!(sanitized.contains("charset"));
}
#[test]
fn test_sanitize_for_xpath_multiple_void_elements() {
let html = r#"<br><img src="x.png"><input type="text"><link rel="stylesheet">"#;
let sanitized = sanitize_for_xpath(html);
assert!(sanitized.contains("br"));
assert!(sanitized.contains("img"));
assert!(sanitized.contains("input"));
assert!(sanitized.contains("link"));
}
#[test]
fn test_sanitize_for_xpath_non_void_unchanged() {
let html = r#"<div><p>Hello</p></div>"#;
let sanitized = sanitize_for_xpath(html);
assert!(sanitized.contains("<div>"));
assert!(sanitized.contains("<p>"));
}
}
pub fn sanitize_for_xpath(html: &str) -> String {
let doc = Html::parse_document(html);
doc.html()
}