use dom_smoothie::{Config, 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 document = Html::parse_document(html_content);
Article {
title: extract_title(&document),
content: extract_content_heuristic(&document),
date: extract_meta_content(&document, "date")
.or_else(|| extract_meta_content(&document, "article:published_time")),
author: extract_meta_content(&document, "author")
.or_else(|| extract_meta_content(&document, "article:author")),
description: extract_meta_content(&document, "description")
.or_else(|| extract_meta_content(&document, "og:description")),
}
}
fn extract_title(document: &Html) -> String {
if let Ok(sel) = Selector::parse(r#"meta[property="og:title"]"#)
&& let Some(el) = document.select(&sel).next()
&& let Some(content) = el.value().attr("content")
&& !content.is_empty()
{
return content.to_string();
}
if let Ok(sel) = Selector::parse("h1")
&& let Some(el) = document.select(&sel).next()
{
let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
if !text.is_empty() {
return text;
}
}
if let Ok(sel) = Selector::parse("title")
&& let Some(el) = document.select(&sel).next()
{
let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
if !text.is_empty() {
return text;
}
}
String::new()
}
fn extract_content_heuristic(document: &Html) -> String {
if let Ok(sel) = Selector::parse("article")
&& let Some(el) = document.select(&sel).next()
{
let text = element_to_text(&el);
if text.len() > 100 {
return text;
}
}
let content_selectors = &[
"article-body",
"post-content",
"entry-content",
"article-content",
"news-content",
"story-body",
".content-article",
"#article-body",
".article-body",
".post-body",
];
for selector_str in content_selectors {
if let Ok(sel) = Selector::parse(selector_str)
&& let Some(el) = document.select(&sel).next()
{
let text = element_to_text(&el);
if text.len() > 100 {
return text;
}
}
}
if let Ok(sel) = Selector::parse("div") {
let divs: Vec<_> = document.select(&sel).collect();
let mut best = String::new();
for div in divs {
let text = element_to_text(&div);
if text.len() > 200 && text.len() < 50_000 && text.len() > best.len() {
best = text;
}
}
if !best.is_empty() {
return best;
}
}
String::new()
}
fn extract_meta_content(document: &Html, name: &str) -> Option<String> {
let sel_str = format!(r#"meta[name="{name}"]"#);
if let Ok(sel) = Selector::parse(&sel_str)
&& let Some(el) = document.select(&sel).next()
&& let Some(content) = el.value().attr("content")
{
let content = content.trim().to_string();
if !content.is_empty() {
return Some(content);
}
}
let sel_str = format!(r#"meta[property="{name}"]"#);
if let Ok(sel) = Selector::parse(&sel_str)
&& let Some(el) = document.select(&sel).next()
&& let Some(content) = el.value().attr("content")
{
let content = content.trim().to_string();
if !content.is_empty() {
return Some(content);
}
}
None
}
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)
}
fn element_to_text(element: &scraper::ElementRef) -> String {
let mut result = String::new();
for text_piece in element.text() {
let t = text_piece.trim();
if !t.is_empty() {
result.push_str(t);
result.push('\n');
}
}
let lines: Vec<&str> = result
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
lines.join("\n")
}
#[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()
}