1use dom_smoothie::{Config, 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 {
193 let document = Html::parse_document(html_content);
194
195 Article {
196 title: extract_title(&document),
197 content: extract_content_heuristic(&document),
198 date: extract_meta_content(&document, "date")
199 .or_else(|| extract_meta_content(&document, "article:published_time")),
200 author: extract_meta_content(&document, "author")
201 .or_else(|| extract_meta_content(&document, "article:author")),
202 description: extract_meta_content(&document, "description")
203 .or_else(|| extract_meta_content(&document, "og:description")),
204 }
205}
206
207fn extract_title(document: &Html) -> String {
209 if let Ok(sel) = Selector::parse(r#"meta[property="og:title"]"#)
210 && let Some(el) = document.select(&sel).next()
211 && let Some(content) = el.value().attr("content")
212 && !content.is_empty()
213 {
214 return content.to_string();
215 }
216 if let Ok(sel) = Selector::parse("h1")
217 && let Some(el) = document.select(&sel).next()
218 {
219 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
220 if !text.is_empty() {
221 return text;
222 }
223 }
224 if let Ok(sel) = Selector::parse("title")
225 && let Some(el) = document.select(&sel).next()
226 {
227 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
228 if !text.is_empty() {
229 return text;
230 }
231 }
232 String::new()
233}
234
235fn extract_content_heuristic(document: &Html) -> String {
237 if let Ok(sel) = Selector::parse("article")
239 && let Some(el) = document.select(&sel).next()
240 {
241 let text = element_to_text(&el);
242 if text.len() > 100 {
243 return text;
244 }
245 }
246
247 let content_selectors = &[
249 "article-body",
250 "post-content",
251 "entry-content",
252 "article-content",
253 "news-content",
254 "story-body",
255 ".content-article",
256 "#article-body",
257 ".article-body",
258 ".post-body",
259 ];
260
261 for selector_str in content_selectors {
262 if let Ok(sel) = Selector::parse(selector_str)
263 && let Some(el) = document.select(&sel).next()
264 {
265 let text = element_to_text(&el);
266 if text.len() > 100 {
267 return text;
268 }
269 }
270 }
271
272 if let Ok(sel) = Selector::parse("div") {
274 let divs: Vec<_> = document.select(&sel).collect();
275 let mut best = String::new();
276 for div in divs {
277 let text = element_to_text(&div);
278 if text.len() > 200 && text.len() < 50_000 && text.len() > best.len() {
279 best = text;
280 }
281 }
282 if !best.is_empty() {
283 return best;
284 }
285 }
286
287 String::new()
288}
289
290fn extract_meta_content(document: &Html, name: &str) -> Option<String> {
292 let sel_str = format!(r#"meta[name="{name}"]"#);
293 if let Ok(sel) = Selector::parse(&sel_str)
294 && let Some(el) = document.select(&sel).next()
295 && let Some(content) = el.value().attr("content")
296 {
297 let content = content.trim().to_string();
298 if !content.is_empty() {
299 return Some(content);
300 }
301 }
302 let sel_str = format!(r#"meta[property="{name}"]"#);
303 if let Ok(sel) = Selector::parse(&sel_str)
304 && let Some(el) = document.select(&sel).next()
305 && let Some(content) = el.value().attr("content")
306 {
307 let content = content.trim().to_string();
308 if !content.is_empty() {
309 return Some(content);
310 }
311 }
312 None
313}
314
315pub fn extract_readable_content(raw_html: &str) -> Result<String> {
326 let cfg = Config {
327 text_mode: TextMode::Markdown,
328 ..Default::default()
329 };
330
331 let mut readability = dom_smoothie::Readability::new(raw_html, None, Some(cfg))
332 .map_err(|e| CrawlError::Readability(e.to_string()))?;
333
334 let article = readability
335 .parse()
336 .map_err(|e| CrawlError::Readability(e.to_string()))?;
337
338 Ok(article.content.to_string())
339}
340
341pub fn extract_content_by_selector(raw_html: &str, content_selector: &str) -> Result<String> {
348 let document = Html::parse_document(raw_html);
349 let selector = Selector::parse(content_selector).map_err(|e| CrawlError::Selector {
350 selector: content_selector.to_string(),
351 message: e.to_string(),
352 })?;
353
354 let content = document
355 .select(&selector)
356 .next()
357 .map(|el| el.text().collect::<Vec<_>>().join("\n").trim().to_string())
358 .unwrap_or_default();
359
360 Ok(content)
361}
362
363pub fn extract_content(raw_html: &str, content_selector: &str) -> Result<String> {
370 match extract_readable_content(raw_html) {
371 Ok(content) if !content.is_empty() && content.len() > 100 => Ok(content),
372 _ => extract_content_by_selector(raw_html, content_selector),
373 }
374}
375
376pub fn extract_texts(raw_html: &str, selector: &str) -> Result<Vec<String>> {
383 let document = Html::parse_document(raw_html);
384 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
385 selector: selector.to_string(),
386 message: e.to_string(),
387 })?;
388
389 let texts: Vec<String> = document
390 .select(&sel)
391 .filter_map(|el| {
392 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
393 if text.is_empty() { None } else { Some(text) }
394 })
395 .collect();
396
397 Ok(texts)
398}
399
400pub fn extract_attributes(raw_html: &str, selector: &str, attr: &str) -> Result<Vec<String>> {
402 let document = Html::parse_document(raw_html);
403 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
404 selector: selector.to_string(),
405 message: e.to_string(),
406 })?;
407
408 let values: Vec<String> = document
409 .select(&sel)
410 .filter_map(|el| el.value().attr(attr).map(ToString::to_string))
411 .filter(|v| !v.is_empty())
412 .collect();
413
414 Ok(values)
415}
416
417fn element_to_text(element: &scraper::ElementRef) -> String {
419 let mut result = String::new();
420 for text_piece in element.text() {
421 let t = text_piece.trim();
422 if !t.is_empty() {
423 result.push_str(t);
424 result.push('\n');
425 }
426 }
427 let lines: Vec<&str> = result
428 .lines()
429 .map(str::trim)
430 .filter(|l| !l.is_empty())
431 .collect();
432 lines.join("\n")
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn sample_html() -> &'static str {
440 r##"<!DOCTYPE html>
441<html>
442<body>
443 <a class="link" href="https://example.com/article/1">文章1</a>
444 <a class="link" href="/article/2">文章2</a>
445 <a class="link" href="//cdn.example.com/article/3">文章3</a>
446 <a class="link" href="#top">锚点</a>
447 <a class="link" href="javascript:void(0)">脚本</a>
448 <a class="link" href="mailto:test@example.com">邮箱</a>
449</body>
450</html>"##
451 }
452
453 #[test]
454 fn try_extract_links_returns_selector_error() {
455 let result = try_extract_links("<html></html>", "!!!invalid");
456 assert!(result.is_err());
457 }
458
459 #[test]
460 fn extract_absolute_links_by_css_filters_and_resolves() {
461 let links = extract_absolute_links(
462 sample_html(),
463 "a.link",
464 LinkSelectorType::Css,
465 "https://example.com/base/",
466 )
467 .unwrap();
468
469 assert_eq!(links.len(), 3);
470 assert!(links.contains(&"https://example.com/article/1".to_string()));
471 assert!(links.contains(&"https://example.com/article/2".to_string()));
472 assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
473 }
474
475 #[test]
476 fn extract_absolute_links_by_xpath_reads_href_attributes() {
477 let links = extract_absolute_links(
478 sample_html(),
479 "//a[@class='link']/@href",
480 LinkSelectorType::Xpath,
481 "https://example.com",
482 )
483 .unwrap();
484
485 assert_eq!(links.len(), 3);
486 assert!(links.contains(&"https://example.com/article/1".to_string()));
487 assert!(links.contains(&"https://example.com/article/2".to_string()));
488 assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
489 }
490
491 #[test]
492 fn resolve_url_skips_non_page_links() {
493 assert!(resolve_url("https://example.com", "javascript:void(0)").is_none());
494 assert!(resolve_url("https://example.com", "mailto:test@example.com").is_none());
495 assert!(resolve_url("https://example.com", "#top").is_none());
496 }
497
498 #[test]
499 fn test_sanitize_for_xpath_meta() {
500 let html = r#"<html><head><meta charset="utf-8"></head><body></body></html>"#;
501 let sanitized = sanitize_for_xpath(html);
502 assert!(sanitized.contains("meta"));
503 assert!(sanitized.contains("charset"));
504 }
505
506 #[test]
507 fn test_sanitize_for_xpath_self_closing_unchanged() {
508 let html = r#"<html><head><meta charset="utf-8" /></head><body></body></html>"#;
509 let sanitized = sanitize_for_xpath(html);
510 assert!(sanitized.contains("meta"));
511 assert!(sanitized.contains("charset"));
512 }
513
514 #[test]
515 fn test_sanitize_for_xpath_multiple_void_elements() {
516 let html = r#"<br><img src="x.png"><input type="text"><link rel="stylesheet">"#;
517 let sanitized = sanitize_for_xpath(html);
518 assert!(sanitized.contains("br"));
519 assert!(sanitized.contains("img"));
520 assert!(sanitized.contains("input"));
521 assert!(sanitized.contains("link"));
522 }
523
524 #[test]
525 fn test_sanitize_for_xpath_non_void_unchanged() {
526 let html = r#"<div><p>Hello</p></div>"#;
527 let sanitized = sanitize_for_xpath(html);
528 assert!(sanitized.contains("<div>"));
529 assert!(sanitized.contains("<p>"));
530 }
531}
532
533pub fn sanitize_for_xpath(html: &str) -> String {
539 let doc = Html::parse_document(html);
540 doc.html()
541}