1use dom_smoothie::{Config, Readability, TextMode};
7use regex::Regex;
8use scraper::{Html, Selector};
9use skyscraper::{
10 html as xpath_html,
11 xpath::{
12 self, XpathItemTree,
13 grammar::{
14 NonTreeXpathNode, XpathItemTreeNodeData,
15 data_model::{Node, XpathItem},
16 },
17 },
18};
19use url::Url;
20
21use crawlkit_core::error::{CrawlError, Result};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum LinkSelectorType {
26 Css,
28 Xpath,
30}
31
32pub fn extract_links(html_content: &str, selector: &str) -> Vec<String> {
39 try_extract_links(html_content, selector).unwrap_or_default()
40}
41
42pub fn try_extract_links(html_content: &str, selector: &str) -> Result<Vec<String>> {
46 let document = Html::parse_document(html_content);
47 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
48 selector: selector.to_string(),
49 message: e.to_string(),
50 })?;
51
52 let mut seen = std::collections::HashSet::new();
53 let mut links = Vec::new();
54
55 for element in document.select(&sel) {
56 if let Some(href) = element.value().attr("href")
57 && seen.insert(href.to_string())
58 {
59 links.push(href.to_string());
60 }
61 }
62
63 Ok(links)
64}
65
66pub fn extract_links_by_selector(
70 html_content: &str,
71 selector: &str,
72 selector_type: LinkSelectorType,
73) -> Result<Vec<String>> {
74 match selector_type {
75 LinkSelectorType::Css => try_extract_links(html_content, selector),
76 LinkSelectorType::Xpath => extract_links_by_xpath(html_content, selector),
77 }
78}
79
80fn is_xml_content(content: &str) -> bool {
82 let trimmed = content.trim_start();
83 trimmed.starts_with("<?xml")
84 || trimmed.starts_with("<rss")
85 || trimmed.starts_with("<feed")
86 || trimmed.starts_with("<sitemap")
87 || trimmed.starts_with("<urlset")
88}
89
90fn extract_links_from_xml(xml_content: &str) -> Vec<String> {
94 let mut links = Vec::new();
95 let mut seen = std::collections::HashSet::new();
96
97 let re_text_link = Regex::new(r"<link>\s*(https?://[^<]+?)\s*</link>").unwrap();
100 let re_attr_link = Regex::new(r#"<link[^>]+href="(https?://[^"]+)""#).unwrap();
101
102 for cap in re_text_link.captures_iter(xml_content) {
103 if let Some(url) = cap.get(1) {
104 let url = url.as_str().trim().to_string();
105 if !url.is_empty() && seen.insert(url.clone()) {
106 links.push(url);
107 }
108 }
109 }
110
111 for cap in re_attr_link.captures_iter(xml_content) {
112 if let Some(url) = cap.get(1) {
113 let url = url.as_str().trim().to_string();
114 if !url.is_empty() && seen.insert(url.clone()) {
115 links.push(url);
116 }
117 }
118 }
119
120 links
121}
122
123pub fn extract_links_by_xpath(html_content: &str, selector: &str) -> Result<Vec<String>> {
129 if is_xml_content(html_content) {
131 return Ok(extract_links_from_xml(html_content));
132 }
133
134 let sanitized = sanitize_for_xpath(html_content);
135 let document = xpath_html::parse(&sanitized).map_err(|e| CrawlError::Html(e.to_string()))?;
136 let tree = XpathItemTree::from(&document);
137 let xpath_expr = xpath::parse(selector).map_err(|e| CrawlError::Selector {
138 selector: selector.to_string(),
139 message: e.to_string(),
140 })?;
141 let item_set = xpath_expr
142 .apply(&tree)
143 .map_err(|e| CrawlError::Html(e.to_string()))?;
144
145 let mut seen = std::collections::HashSet::new();
146 let mut links = Vec::new();
147
148 for item in &item_set {
149 let href = match item {
150 XpathItem::Node(Node::NonTreeNode(NonTreeXpathNode::AttributeNode(attribute))) => {
151 attribute.value.clone()
152 }
153 XpathItem::Node(Node::TreeNode(tree_node)) => match &tree_node.data {
154 XpathItemTreeNodeData::ElementNode(element) => element
155 .get_attribute("href")
156 .map(str::to_string)
157 .unwrap_or_default(),
158 XpathItemTreeNodeData::TextNode(text) => text.to_string(),
159 _ => String::new(),
160 },
161 _ => String::new(),
162 };
163
164 let href = href.trim();
165 if !href.is_empty() && seen.insert(href.to_string()) {
166 links.push(href.to_string());
167 }
168 }
169
170 Ok(links)
171}
172
173pub fn resolve_url(base_url: &str, relative: &str) -> Option<String> {
178 let relative = relative.trim();
179 if should_skip_link(relative) {
180 return None;
181 }
182 let base = Url::parse(base_url).ok()?;
183 let resolved = base.join(relative).ok()?;
184 match resolved.scheme() {
185 "http" | "https" => Some(resolved.to_string()),
186 _ => None,
187 }
188}
189
190pub fn extract_absolute_links(
192 html_content: &str,
193 selector: &str,
194 selector_type: LinkSelectorType,
195 base_url: &str,
196) -> Result<Vec<String>> {
197 let links = extract_links_by_selector(html_content, selector, selector_type)?;
198 let mut seen = std::collections::HashSet::new();
199 let mut absolute_links = Vec::new();
200
201 for link in links {
202 if let Some(url) = resolve_url(base_url, &link)
203 && seen.insert(url.clone())
204 {
205 absolute_links.push(url);
206 }
207 }
208
209 Ok(absolute_links)
210}
211
212fn should_skip_link(link: &str) -> bool {
213 link.is_empty()
214 || link.starts_with('#')
215 || link.starts_with("javascript:")
216 || link.starts_with("mailto:")
217 || link.starts_with("tel:")
218 || link.starts_with("data:")
219}
220
221#[derive(Debug, Clone, Default)]
223pub struct Article {
224 pub title: String,
226 pub content: String,
228 pub date: Option<String>,
230 pub author: Option<String>,
232 pub description: Option<String>,
234}
235
236pub fn extract_article(html_content: &str, base_url: &str) -> Article {
243 let cfg = Config {
244 text_mode: TextMode::Markdown,
245 ..Default::default()
246 };
247
248 let url = if base_url.is_empty() { None } else { Some(base_url) };
249
250 let mut readability = match Readability::new(html_content, url, Some(cfg)) {
251 Ok(r) => r,
252 Err(_) => return Article::default(),
253 };
254
255 let dom_article = match readability.parse() {
256 Ok(a) => a,
257 Err(_) => return Article::default(),
258 };
259
260 let content = dom_article.text_content.to_string();
261
262 Article {
263 title: dom_article.title,
264 content,
265 date: dom_article.published_time,
266 author: dom_article.byline,
267 description: dom_article.excerpt,
268 }
269}
270
271pub fn extract_readable_content(raw_html: &str) -> Result<String> {
282 let cfg = Config {
283 text_mode: TextMode::Markdown,
284 ..Default::default()
285 };
286
287 let mut readability = dom_smoothie::Readability::new(raw_html, None, Some(cfg))
288 .map_err(|e| CrawlError::Readability(e.to_string()))?;
289
290 let article = readability
291 .parse()
292 .map_err(|e| CrawlError::Readability(e.to_string()))?;
293
294 Ok(article.content.to_string())
295}
296
297pub fn extract_content_by_selector(raw_html: &str, content_selector: &str) -> Result<String> {
304 let document = Html::parse_document(raw_html);
305 let selector = Selector::parse(content_selector).map_err(|e| CrawlError::Selector {
306 selector: content_selector.to_string(),
307 message: e.to_string(),
308 })?;
309
310 let content = document
311 .select(&selector)
312 .next()
313 .map(|el| el.text().collect::<Vec<_>>().join("\n").trim().to_string())
314 .unwrap_or_default();
315
316 Ok(content)
317}
318
319pub fn extract_content(raw_html: &str, content_selector: &str) -> Result<String> {
326 match extract_readable_content(raw_html) {
327 Ok(content) if !content.is_empty() && content.len() > 100 => Ok(content),
328 _ => extract_content_by_selector(raw_html, content_selector),
329 }
330}
331
332pub fn extract_texts(raw_html: &str, selector: &str) -> Result<Vec<String>> {
339 let document = Html::parse_document(raw_html);
340 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
341 selector: selector.to_string(),
342 message: e.to_string(),
343 })?;
344
345 let texts: Vec<String> = document
346 .select(&sel)
347 .filter_map(|el| {
348 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
349 if text.is_empty() { None } else { Some(text) }
350 })
351 .collect();
352
353 Ok(texts)
354}
355
356pub fn extract_attributes(raw_html: &str, selector: &str, attr: &str) -> Result<Vec<String>> {
358 let document = Html::parse_document(raw_html);
359 let sel = Selector::parse(selector).map_err(|e| CrawlError::Selector {
360 selector: selector.to_string(),
361 message: e.to_string(),
362 })?;
363
364 let values: Vec<String> = document
365 .select(&sel)
366 .filter_map(|el| el.value().attr(attr).map(ToString::to_string))
367 .filter(|v| !v.is_empty())
368 .collect();
369
370 Ok(values)
371}
372
373pub fn sanitize_for_xpath(html: &str) -> String {
379 let doc = Html::parse_document(html);
380 doc.html()
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 fn sample_html() -> &'static str {
388 r##"<!DOCTYPE html>
389<html>
390<body>
391 <a class="link" href="https://example.com/article/1">文章1</a>
392 <a class="link" href="/article/2">文章2</a>
393 <a class="link" href="//cdn.example.com/article/3">文章3</a>
394 <a class="link" href="#top">锚点</a>
395 <a class="link" href="javascript:void(0)">脚本</a>
396 <a class="link" href="mailto:test@example.com">邮箱</a>
397</body>
398</html>"##
399 }
400
401 #[test]
402 fn try_extract_links_returns_selector_error() {
403 let result = try_extract_links("<html></html>", "!!!invalid");
404 assert!(result.is_err());
405 }
406
407 #[test]
408 fn extract_absolute_links_by_css_filters_and_resolves() {
409 let links = extract_absolute_links(
410 sample_html(),
411 "a.link",
412 LinkSelectorType::Css,
413 "https://example.com/base/",
414 )
415 .unwrap();
416
417 assert_eq!(links.len(), 3);
418 assert!(links.contains(&"https://example.com/article/1".to_string()));
419 assert!(links.contains(&"https://example.com/article/2".to_string()));
420 assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
421 }
422
423 #[test]
424 fn extract_absolute_links_by_xpath_reads_href_attributes() {
425 let links = extract_absolute_links(
426 sample_html(),
427 "//a[@class='link']/@href",
428 LinkSelectorType::Xpath,
429 "https://example.com",
430 )
431 .unwrap();
432
433 assert_eq!(links.len(), 3);
434 assert!(links.contains(&"https://example.com/article/1".to_string()));
435 assert!(links.contains(&"https://example.com/article/2".to_string()));
436 assert!(links.contains(&"https://cdn.example.com/article/3".to_string()));
437 }
438
439 #[test]
440 fn resolve_url_skips_non_page_links() {
441 assert!(resolve_url("https://example.com", "javascript:void(0)").is_none());
442 assert!(resolve_url("https://example.com", "mailto:test@example.com").is_none());
443 assert!(resolve_url("https://example.com", "#top").is_none());
444 }
445
446 #[test]
447 fn test_sanitize_for_xpath_meta() {
448 let html = r#"<html><head><meta charset="utf-8"></head><body></body></html>"#;
449 let sanitized = sanitize_for_xpath(html);
450 assert!(sanitized.contains("meta"));
451 assert!(sanitized.contains("charset"));
452 }
453
454 #[test]
455 fn test_sanitize_for_xpath_self_closing_unchanged() {
456 let html = r#"<html><head><meta charset="utf-8" /></head><body></body></html>"#;
457 let sanitized = sanitize_for_xpath(html);
458 assert!(sanitized.contains("meta"));
459 assert!(sanitized.contains("charset"));
460 }
461
462 #[test]
463 fn test_sanitize_for_xpath_multiple_void_elements() {
464 let html = r#"<br><img src="x.png"><input type="text"><link rel="stylesheet">"#;
465 let sanitized = sanitize_for_xpath(html);
466 assert!(sanitized.contains("br"));
467 assert!(sanitized.contains("img"));
468 assert!(sanitized.contains("input"));
469 assert!(sanitized.contains("link"));
470 }
471
472 #[test]
473 fn test_sanitize_for_xpath_non_void_unchanged() {
474 let html = r"<div><p>Hello</p></div>";
475 let sanitized = sanitize_for_xpath(html);
476 assert!(sanitized.contains("<div>"));
477 assert!(sanitized.contains("<p>"));
478 }
479
480 #[test]
481 fn test_rss_xml_xpath_text_node() {
482 let rss = r#"<?xml version="1.0" encoding="utf-8"?>
483<rss version="2.0">
484 <channel>
485 <item>
486 <link>https://example.com/article1</link>
487 </item>
488 <item>
489 <link>https://example.com/article2</link>
490 </item>
491 </channel>
492</rss>"#;
493 let links = extract_links_by_xpath(rss, "//item/link").unwrap();
494 assert_eq!(links.len(), 2);
495 assert!(links.contains(&"https://example.com/article1".to_string()));
496 assert!(links.contains(&"https://example.com/article2".to_string()));
497 }
498
499 #[test]
500 fn test_atom_xml_xpath_link_attr() {
501 let atom = r#"<?xml version="1.0" encoding="utf-8"?>
502<feed xmlns="http://www.w3.org/2005/Atom">
503 <entry>
504 <link href="https://example.com/atom1"/>
505 </entry>
506 <entry>
507 <link href="https://example.com/atom2"/>
508 </entry>
509</feed>"#;
510 let links = extract_links_by_xpath(atom, "//entry/link/@href").unwrap();
511 assert_eq!(links.len(), 2);
512 assert!(links.contains(&"https://example.com/atom1".to_string()));
513 assert!(links.contains(&"https://example.com/atom2".to_string()));
514 }
515}