1use dom_smoothie::{Config, Readability, TextMode};
7use scraper::{Html, Selector};
8use skyscraper::{
9 html as xpath_html,
10 xpath::{
11 self, XpathItemTree,
12 grammar::{
13 NonTreeXpathNode, XpathItemTreeNodeData,
14 data_model::{Node, XpathItem},
15 },
16 },
17};
18use url::Url;
19
20use crawlkit_core::error::{CrawlError, Result};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum LinkSelectorType {
25 Css,
27 Xpath,
29}
30
31pub fn extract_links(html_content: &str, selector: &str) -> Vec<String> {
38 try_extract_links(html_content, selector).unwrap_or_default()
39}
40
41pub fn try_extract_links(html_content: &str, selector: &str) -> Result<Vec<String>> {
45 let document = Html::parse_document(html_content);
46 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
47 selector: selector.to_string(),
48 message: e.to_string(),
49 })?;
50
51 let mut seen = std::collections::HashSet::new();
52 let mut links = Vec::new();
53
54 for element in document.select(&sel) {
55 if let Some(href) = element.value().attr("href")
56 && seen.insert(href.to_string())
57 {
58 links.push(href.to_string());
59 }
60 }
61
62 Ok(links)
63}
64
65pub fn extract_links_by_selector(
69 html_content: &str,
70 selector: &str,
71 selector_type: LinkSelectorType,
72) -> Result<Vec<String>> {
73 match selector_type {
74 LinkSelectorType::Css => try_extract_links(html_content, selector),
75 LinkSelectorType::Xpath => extract_links_by_xpath(html_content, selector),
76 }
77}
78
79pub fn extract_links_by_xpath(html_content: &str, selector: &str) -> Result<Vec<String>> {
81 let sanitized = sanitize_for_xpath(html_content);
82 let document = xpath_html::parse(&sanitized).map_err(|e| CrawlError::Html(e.to_string()))?;
83 let tree = XpathItemTree::from(&document);
84 let xpath_expr = xpath::parse(selector).map_err(|e| CrawlError::Selector {
85 selector: selector.to_string(),
86 message: e.to_string(),
87 })?;
88 let item_set = xpath_expr
89 .apply(&tree)
90 .map_err(|e| CrawlError::Html(e.to_string()))?;
91
92 let mut seen = std::collections::HashSet::new();
93 let mut links = Vec::new();
94
95 for item in &item_set {
96 let href = match item {
97 XpathItem::Node(Node::NonTreeNode(NonTreeXpathNode::AttributeNode(attribute))) => {
98 attribute.value.clone()
99 }
100 XpathItem::Node(Node::TreeNode(tree_node)) => match tree_node.data {
101 XpathItemTreeNodeData::ElementNode(element) => element
102 .get_attribute("href")
103 .map(str::to_string)
104 .unwrap_or_default(),
105 _ => String::new(),
106 },
107 _ => String::new(),
108 };
109
110 let href = href.trim();
111 if !href.is_empty() && seen.insert(href.to_string()) {
112 links.push(href.to_string());
113 }
114 }
115
116 Ok(links)
117}
118
119pub fn resolve_url(base_url: &str, relative: &str) -> Option<String> {
124 let relative = relative.trim();
125 if should_skip_link(relative) {
126 return None;
127 }
128 let base = Url::parse(base_url).ok()?;
129 let resolved = base.join(relative).ok()?;
130 match resolved.scheme() {
131 "http" | "https" => Some(resolved.to_string()),
132 _ => None,
133 }
134}
135
136pub fn extract_absolute_links(
138 html_content: &str,
139 selector: &str,
140 selector_type: LinkSelectorType,
141 base_url: &str,
142) -> Result<Vec<String>> {
143 let links = extract_links_by_selector(html_content, selector, selector_type)?;
144 let mut seen = std::collections::HashSet::new();
145 let mut absolute_links = Vec::new();
146
147 for link in links {
148 if let Some(url) = resolve_url(base_url, &link)
149 && seen.insert(url.clone())
150 {
151 absolute_links.push(url);
152 }
153 }
154
155 Ok(absolute_links)
156}
157
158fn should_skip_link(link: &str) -> bool {
159 link.is_empty()
160 || link.starts_with('#')
161 || link.starts_with("javascript:")
162 || link.starts_with("mailto:")
163 || link.starts_with("tel:")
164 || link.starts_with("data:")
165}
166
167#[derive(Debug, Clone, Default)]
169pub struct Article {
170 pub title: String,
172 pub content: String,
174 pub date: Option<String>,
176 pub author: Option<String>,
178 pub description: Option<String>,
180}
181
182pub fn extract_article(html_content: &str, base_url: &str) -> Article {
189 let cfg = Config {
190 text_mode: TextMode::Markdown,
191 ..Default::default()
192 };
193
194 let url = if base_url.is_empty() { None } else { Some(base_url) };
195
196 let mut readability = match Readability::new(html_content, url, Some(cfg)) {
197 Ok(r) => r,
198 Err(_) => return Article::default(),
199 };
200
201 let dom_article = match readability.parse() {
202 Ok(a) => a,
203 Err(_) => return Article::default(),
204 };
205
206 let content = dom_article.text_content.to_string();
207
208 Article {
209 title: dom_article.title,
210 content,
211 date: dom_article.published_time,
212 author: dom_article.byline,
213 description: dom_article.excerpt,
214 }
215}
216
217pub fn extract_readable_content(raw_html: &str) -> Result<String> {
228 let cfg = Config {
229 text_mode: TextMode::Markdown,
230 ..Default::default()
231 };
232
233 let mut readability = dom_smoothie::Readability::new(raw_html, None, Some(cfg))
234 .map_err(|e| CrawlError::Readability(e.to_string()))?;
235
236 let article = readability
237 .parse()
238 .map_err(|e| CrawlError::Readability(e.to_string()))?;
239
240 Ok(article.content.to_string())
241}
242
243pub fn extract_content_by_selector(raw_html: &str, content_selector: &str) -> Result<String> {
250 let document = Html::parse_document(raw_html);
251 let selector = Selector::parse(content_selector).map_err(|e| CrawlError::Selector {
252 selector: content_selector.to_string(),
253 message: e.to_string(),
254 })?;
255
256 let content = document
257 .select(&selector)
258 .next()
259 .map(|el| el.text().collect::<Vec<_>>().join("\n").trim().to_string())
260 .unwrap_or_default();
261
262 Ok(content)
263}
264
265pub fn extract_content(raw_html: &str, content_selector: &str) -> Result<String> {
272 match extract_readable_content(raw_html) {
273 Ok(content) if !content.is_empty() && content.len() > 100 => Ok(content),
274 _ => extract_content_by_selector(raw_html, content_selector),
275 }
276}
277
278pub fn extract_texts(raw_html: &str, selector: &str) -> Result<Vec<String>> {
285 let document = Html::parse_document(raw_html);
286 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
287 selector: selector.to_string(),
288 message: e.to_string(),
289 })?;
290
291 let texts: Vec<String> = document
292 .select(&sel)
293 .filter_map(|el| {
294 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
295 if text.is_empty() { None } else { Some(text) }
296 })
297 .collect();
298
299 Ok(texts)
300}
301
302pub fn extract_attributes(raw_html: &str, selector: &str, attr: &str) -> Result<Vec<String>> {
304 let document = Html::parse_document(raw_html);
305 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
306 selector: selector.to_string(),
307 message: e.to_string(),
308 })?;
309
310 let values: Vec<String> = document
311 .select(&sel)
312 .filter_map(|el| el.value().attr(attr).map(ToString::to_string))
313 .filter(|v| !v.is_empty())
314 .collect();
315
316 Ok(values)
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn sample_html() -> &'static str {
324 r##"<!DOCTYPE html>
325<html>
326<body>
327 <a class="link" href="https://example.com/article/1">文章1</a>
328 <a class="link" href="/article/2">文章2</a>
329 <a class="link" href="//cdn.example.com/article/3">文章3</a>
330 <a class="link" href="#top">锚点</a>
331 <a class="link" href="javascript:void(0)">脚本</a>
332 <a class="link" href="mailto:test@example.com">邮箱</a>
333</body>
334</html>"##
335 }
336
337 #[test]
338 fn try_extract_links_returns_selector_error() {
339 let result = try_extract_links("<html></html>", "!!!invalid");
340 assert!(result.is_err());
341 }
342
343 #[test]
344 fn extract_absolute_links_by_css_filters_and_resolves() {
345 let links = extract_absolute_links(
346 sample_html(),
347 "a.link",
348 LinkSelectorType::Css,
349 "https://example.com/base/",
350 )
351 .unwrap();
352
353 assert_eq!(links.len(), 3);
354 assert!(links.contains(&"https://example.com/article/1".to_string()));
355 assert!(links.contains(&"https://example.com/article/2".to_string()));
356 assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
357 }
358
359 #[test]
360 fn extract_absolute_links_by_xpath_reads_href_attributes() {
361 let links = extract_absolute_links(
362 sample_html(),
363 "//a[@class='link']/@href",
364 LinkSelectorType::Xpath,
365 "https://example.com",
366 )
367 .unwrap();
368
369 assert_eq!(links.len(), 3);
370 assert!(links.contains(&"https://example.com/article/1".to_string()));
371 assert!(links.contains(&"https://example.com/article/2".to_string()));
372 assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
373 }
374
375 #[test]
376 fn resolve_url_skips_non_page_links() {
377 assert!(resolve_url("https://example.com", "javascript:void(0)").is_none());
378 assert!(resolve_url("https://example.com", "mailto:test@example.com").is_none());
379 assert!(resolve_url("https://example.com", "#top").is_none());
380 }
381
382 #[test]
383 fn test_sanitize_for_xpath_meta() {
384 let html = r#"<html><head><meta charset="utf-8"></head><body></body></html>"#;
385 let sanitized = sanitize_for_xpath(html);
386 assert!(sanitized.contains("meta"));
387 assert!(sanitized.contains("charset"));
388 }
389
390 #[test]
391 fn test_sanitize_for_xpath_self_closing_unchanged() {
392 let html = r#"<html><head><meta charset="utf-8" /></head><body></body></html>"#;
393 let sanitized = sanitize_for_xpath(html);
394 assert!(sanitized.contains("meta"));
395 assert!(sanitized.contains("charset"));
396 }
397
398 #[test]
399 fn test_sanitize_for_xpath_multiple_void_elements() {
400 let html = r#"<br><img src="x.png"><input type="text"><link rel="stylesheet">"#;
401 let sanitized = sanitize_for_xpath(html);
402 assert!(sanitized.contains("br"));
403 assert!(sanitized.contains("img"));
404 assert!(sanitized.contains("input"));
405 assert!(sanitized.contains("link"));
406 }
407
408 #[test]
409 fn test_sanitize_for_xpath_non_void_unchanged() {
410 let html = r#"<div><p>Hello</p></div>"#;
411 let sanitized = sanitize_for_xpath(html);
412 assert!(sanitized.contains("<div>"));
413 assert!(sanitized.contains("<p>"));
414 }
415}
416
417pub fn sanitize_for_xpath(html: &str) -> String {
423 let doc = Html::parse_document(html);
424 doc.html()
425}