1use scraper::{Html, ElementRef};
7use std::collections::HashMap;
8use url::Url;
9
10use crate::selector::{
11 SELECTORS, try_parse_selector, meta_name_selector, meta_property_selector, link_rel_selector,
12};
13use crate::types::{
14 PageMetadata, OpenGraph, TwitterCard, RobotsMeta, AlternateLink,
15 StructuredData, ParserResult,
16};
17
18pub fn extract_metadata(document: &Html, base_url: Option<&Url>) -> ParserResult<PageMetadata> {
24 let title = extract_title(document);
25 let charset = extract_charset(document);
26 let language = extract_language(document);
27 let base = extract_base_url(document);
28 let description = extract_meta_content(document, "description");
29 let author = extract_meta_content(document, "author");
30 let generator = extract_meta_content(document, "generator");
31 let viewport = extract_meta_content(document, "viewport");
32 let theme_color = extract_meta_content(document, "theme-color");
33 let published_date = extract_meta_content(document, "date")
34 .or_else(|| extract_meta_content(document, "article:published_time"));
35 let modified_date = extract_meta_content(document, "article:modified_time");
36 let keywords = extract_keywords(document);
37 let canonical = extract_canonical(document, base_url);
38 let favicon = extract_favicon(document, base_url);
39 let apple_touch = extract_apple_touch_icon(document, base_url);
40 let robots = extract_robots(document);
41 let opengraph = extract_opengraph(document);
42 let twitter = extract_twitter_card(document);
43 let alternates = extract_alternates(document, base_url);
44 let structured = extract_structured_data(document);
45 let schema_type = structured.first()
46 .and_then(|s| s.schema_type.clone());
47 let custom = extract_custom_meta(document);
48
49 Ok(PageMetadata {
50 title,
51 description,
52 keywords,
53 author,
54 generator,
55 canonical,
56 base_url: base,
57 language,
58 charset,
59 viewport,
60 robots,
61 opengraph,
62 twitter,
63 alternates,
64 favicon,
65 apple_touch_icon: apple_touch,
66 theme_color,
67 published_date,
68 modified_date,
69 schema_type,
70 custom,
71 })
72}
73
74pub fn extract_title(document: &Html) -> Option<String> {
80 if let Some(sel) = try_parse_selector(&meta_property_selector("og:title")) {
81 if let Some(el) = document.select(&sel).next() {
82 if let Some(content) = el.value().attr("content") {
83 let text = content.trim().to_string();
84 if !text.is_empty() {
85 return Some(text);
86 }
87 }
88 }
89 }
90 if let Some(sel) = try_parse_selector(&meta_name_selector("twitter:title")) {
91 if let Some(el) = document.select(&sel).next() {
92 if let Some(content) = el.value().attr("content") {
93 let text = content.trim().to_string();
94 if !text.is_empty() {
95 return Some(text);
96 }
97 }
98 }
99 }
100 let title_sel = &SELECTORS.title;
101 if let Some(el) = document.select(title_sel).next() {
102 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
103 if !text.is_empty() {
104 return Some(text);
105 }
106 }
107 if let Some(sel) = try_parse_selector("h1") {
108 if let Some(el) = document.select(&sel).next() {
109 let text: String = el.text().collect::<Vec<_>>().join("").trim().to_string();
110 if !text.is_empty() {
111 return Some(text);
112 }
113 }
114 }
115 None
116}
117
118pub fn extract_charset(document: &Html) -> Option<String> {
120 if let Some(sel) = try_parse_selector("meta[charset]") {
121 if let Some(el) = document.select(&sel).next() {
122 if let Some(charset) = el.value().attr("charset") {
123 let val = charset.trim().to_string();
124 if !val.is_empty() {
125 return Some(val);
126 }
127 }
128 }
129 }
130 if let Some(sel) = try_parse_selector(r#"meta[http-equiv="Content-Type"]"#) {
131 if let Some(el) = document.select(&sel).next() {
132 if let Some(content) = el.value().attr("content") {
133 if let Some(pos) = content.to_lowercase().find("charset=") {
134 let charset = content[pos + 8..].trim().to_string();
135 if !charset.is_empty() {
136 return Some(charset);
137 }
138 }
139 }
140 }
141 }
142 None
143}
144
145pub fn extract_language(document: &Html) -> Option<String> {
147 let html_sel = &SELECTORS.html;
148 if let Some(el) = document.select(html_sel).next() {
149 if let Some(lang) = el.value().attr("lang") {
150 let val = lang.trim().to_string();
151 if !val.is_empty() {
152 return Some(val);
153 }
154 }
155 }
156 if let Some(sel) = try_parse_selector(r#"meta[http-equiv="content-language"]"#) {
157 if let Some(el) = document.select(&sel).next() {
158 if let Some(content) = el.value().attr("content") {
159 let val = content.trim().to_string();
160 if !val.is_empty() {
161 return Some(val);
162 }
163 }
164 }
165 }
166 None
167}
168
169pub fn extract_base_url(document: &Html) -> Option<String> {
171 let base_sel = &SELECTORS.base;
172 if let Some(el) = document.select(base_sel).next() {
173 if let Some(href) = el.value().attr("href") {
174 let val = href.trim().to_string();
175 if !val.is_empty() {
176 return Some(val);
177 }
178 }
179 }
180 None
181}
182
183pub fn extract_meta_content(document: &Html, name: &str) -> Option<String> {
185 if let Some(sel) = try_parse_selector(&meta_name_selector(name)) {
186 if let Some(el) = document.select(&sel).next() {
187 if let Some(content) = el.value().attr("content") {
188 let val = content.trim().to_string();
189 if !val.is_empty() {
190 return Some(val);
191 }
192 }
193 }
194 }
195 if let Some(sel) = try_parse_selector(&meta_property_selector(name)) {
196 if let Some(el) = document.select(&sel).next() {
197 if let Some(content) = el.value().attr("content") {
198 let val = content.trim().to_string();
199 if !val.is_empty() {
200 return Some(val);
201 }
202 }
203 }
204 }
205 None
206}
207
208pub fn extract_keywords(document: &Html) -> Vec<String> {
210 if let Some(sel) = try_parse_selector(&meta_name_selector("keywords")) {
211 if let Some(el) = document.select(&sel).next() {
212 if let Some(content) = el.value().attr("content") {
213 return content
214 .split(',')
215 .map(|s| s.trim().to_string())
216 .filter(|s| !s.is_empty())
217 .collect();
218 }
219 }
220 }
221 Vec::new()
222}
223
224pub fn extract_canonical(document: &Html, base_url: Option<&Url>) -> Option<String> {
230 if let Some(sel) = try_parse_selector(&link_rel_selector("canonical")) {
231 if let Some(el) = document.select(&sel).next() {
232 if let Some(href) = el.value().attr("href") {
233 return resolve_url(base_url, href);
234 }
235 }
236 }
237 None
238}
239
240pub fn extract_favicon(document: &Html, base_url: Option<&Url>) -> Option<String> {
242 if let Some(sel) = try_parse_selector("link[rel~='icon'], link[rel='shortcut icon'], link[rel='apple-touch-icon-precomposed']") {
243 let mut candidates: Vec<(String, String)> = Vec::new();
244 for el in document.select(&sel) {
245 if let Some(href) = el.value().attr("href") {
246 let sizes = el.value().attr("sizes").unwrap_or("").to_string();
247 candidates.push((href.to_string(), sizes));
248 }
249 }
250 if candidates.is_empty() {
251 return None;
252 }
253 let best = find_largest_icon(&candidates);
254 resolve_url(base_url, &best)
255 } else {
256 None
257 }
258}
259
260pub fn extract_apple_touch_icon(document: &Html, base_url: Option<&Url>) -> Option<String> {
262 if let Some(sel) = try_parse_selector("link[rel='apple-touch-icon'], link[rel='apple-touch-icon-precomposed']") {
263 if let Some(el) = document.select(&sel).next() {
264 if let Some(href) = el.value().attr("href") {
265 return resolve_url(base_url, href);
266 }
267 }
268 }
269 None
270}
271
272pub fn extract_robots(document: &Html) -> RobotsMeta {
278 let content = if let Some(sel) = try_parse_selector("meta[name='robots'], meta[name='ROBOTS']") {
279 document.select(&sel).next()
280 .and_then(|el| el.value().attr("content").map(|c| c.to_string()))
281 } else {
282 None
283 };
284
285 let directives = match content {
286 Some(ref c) => c.split(',').map(|s| s.trim().to_lowercase()).collect::<Vec<_>>(),
287 None => return RobotsMeta::allowed(),
288 };
289
290 let mut meta = RobotsMeta::allowed();
291 meta.raw = content;
292
293 for directive in &directives {
294 match directive.as_str() {
295 "noindex" => meta.index = false,
296 "nofollow" => meta.follow = false,
297 "noarchive" => meta.archive = false,
298 "nocache" => meta.cache = false,
299 "nosnippet" => meta.snippet = false,
300 "all" | "index" | "follow" => {}
301 v if v.starts_with("max-snippet:") => {
302 let val = v.trim_start_matches("max-snippet:").trim();
303 meta.max_snippet = val.parse().unwrap_or(-1);
304 }
305 v if v.starts_with("max-image-preview:") => {
306 let val = v.trim_start_matches("max-image-preview:").trim();
307 meta.max_image_preview = Some(val.to_string());
308 }
309 v if v.starts_with("max-video-preview:") => {
310 let val = v.trim_start_matches("max-video-preview:").trim();
311 meta.max_video_preview = val.parse().unwrap_or(-1);
312 }
313 _ => {}
314 }
315 }
316
317 meta
318}
319
320pub fn extract_opengraph(document: &Html) -> OpenGraph {
326 let mut og = OpenGraph::default();
327 if let Some(title) = extract_og_property(document, "og:title") {
328 og.title = Some(title);
329 }
330 if let Some(og_type) = extract_og_property(document, "og:type") {
331 og.og_type = Some(og_type);
332 }
333 if let Some(url) = extract_og_property(document, "og:url") {
334 og.url = Some(url);
335 }
336 if let Some(image) = extract_og_property(document, "og:image") {
337 og.image = Some(image);
338 }
339 if let Some(desc) = extract_og_property(document, "og:description") {
340 og.description = Some(desc);
341 }
342 if let Some(site) = extract_og_property(document, "og:site_name") {
343 og.site_name = Some(site);
344 }
345 if let Some(locale) = extract_og_property(document, "og:locale") {
346 og.locale = Some(locale);
347 }
348 if let Some(video) = extract_og_property(document, "og:video") {
349 og.video = Some(video);
350 }
351 if let Some(audio) = extract_og_property(document, "og:audio") {
352 og.audio = Some(audio);
353 }
354 og.extra = extract_all_og_properties(document);
355 og
356}
357
358pub fn extract_og_property(document: &Html, property: &str) -> Option<String> {
360 if let Some(sel) = try_parse_selector(&meta_property_selector(property)) {
361 if let Some(el) = document.select(&sel).next() {
362 if let Some(content) = el.value().attr("content") {
363 let val = content.trim().to_string();
364 if !val.is_empty() {
365 return Some(val);
366 }
367 }
368 }
369 }
370 None
371}
372
373pub fn extract_all_og_properties(document: &Html) -> HashMap<String, String> {
375 let mut extras = HashMap::new();
376 let meta_sel = &SELECTORS.meta;
377 let known = [
378 "og:title", "og:type", "og:url", "og:image", "og:description",
379 "og:site_name", "og:locale", "og:video", "og:audio",
380 ];
381 for el in document.select(meta_sel) {
382 if let Some(property) = el.value().attr("property") {
383 let prop_lower = property.to_lowercase();
384 if prop_lower.starts_with("og:") && !known.contains(&prop_lower.as_str()) {
385 if let Some(content) = el.value().attr("content") {
386 extras.insert(property.to_string(), content.to_string());
387 }
388 }
389 }
390 }
391 extras
392}
393
394pub fn extract_twitter_card(document: &Html) -> TwitterCard {
400 let mut card = TwitterCard::default();
401 if let Some(val) = extract_twitter_property(document, "twitter:card") {
402 card.card = Some(val);
403 }
404 if let Some(val) = extract_twitter_property(document, "twitter:site") {
405 card.site = Some(val);
406 }
407 if let Some(val) = extract_twitter_property(document, "twitter:creator") {
408 card.creator = Some(val);
409 }
410 if let Some(val) = extract_twitter_property(document, "twitter:title") {
411 card.title = Some(val);
412 }
413 if let Some(val) = extract_twitter_property(document, "twitter:description") {
414 card.description = Some(val);
415 }
416 if let Some(val) = extract_twitter_property(document, "twitter:image") {
417 card.image = Some(val);
418 }
419 card.extra = extract_all_twitter_properties(document);
420 card
421}
422
423pub fn extract_twitter_property(document: &Html, name: &str) -> Option<String> {
425 if let Some(sel) = try_parse_selector(&meta_name_selector(name)) {
426 if let Some(el) = document.select(&sel).next() {
427 if let Some(content) = el.value().attr("content") {
428 let val = content.trim().to_string();
429 if !val.is_empty() {
430 return Some(val);
431 }
432 }
433 }
434 }
435 None
436}
437
438pub fn extract_all_twitter_properties(document: &Html) -> HashMap<String, String> {
440 let mut extras = HashMap::new();
441 let meta_sel = &SELECTORS.meta;
442 let known = [
443 "twitter:card", "twitter:site", "twitter:creator",
444 "twitter:title", "twitter:description", "twitter:image",
445 ];
446 for el in document.select(meta_sel) {
447 if let Some(name) = el.value().attr("name") {
448 let name_lower = name.to_lowercase();
449 if name_lower.starts_with("twitter:") && !known.contains(&name_lower.as_str()) {
450 if let Some(content) = el.value().attr("content") {
451 extras.insert(name.to_string(), content.to_string());
452 }
453 }
454 }
455 }
456 extras
457}
458
459pub fn extract_alternates(document: &Html, base_url: Option<&Url>) -> Vec<AlternateLink> {
465 let mut alternates = Vec::new();
466 if let Some(sel) = try_parse_selector(&link_rel_selector("alternate")) {
467 for el in document.select(&sel) {
468 let hreflang = el.value().attr("hreflang")
469 .unwrap_or("")
470 .trim()
471 .to_string();
472 let href = el.value().attr("href")
473 .unwrap_or("")
474 .trim()
475 .to_string();
476 if href.is_empty() {
477 continue;
478 }
479 let resolved = resolve_url(base_url, &href).unwrap_or(href);
480 alternates.push(AlternateLink { hreflang, href: resolved });
481 }
482 }
483 alternates
484}
485
486pub fn extract_structured_data(document: &Html) -> Vec<StructuredData> {
492 let mut results = Vec::new();
493 results.extend(extract_json_ld(document));
494 results.extend(extract_microdata(document));
495 results
496}
497
498pub fn extract_json_ld(document: &Html) -> Vec<StructuredData> {
500 let mut results = Vec::new();
501 let json_ld_sel = &SELECTORS.json_ld;
502 for el in document.select(json_ld_sel) {
503 let raw = el.text().collect::<Vec<_>>().join("").trim().to_string();
504 if raw.is_empty() {
505 continue;
506 }
507 let mut sd = StructuredData::json_ld(&raw);
508 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) {
509 if let Some(obj) = val.as_object() {
510 for (k, v) in obj {
511 sd.properties.insert(k.clone(), v.clone());
512 }
513 }
514 if let Some(typ) = val.get("@type").and_then(|t| t.as_str()) {
515 sd.schema_type = Some(typ.to_string());
516 }
517 if let Some(typ) = val.get("type").and_then(|t| t.as_str()) {
518 sd.schema_type = Some(typ.to_string());
519 }
520 }
521 results.push(sd);
522 }
523 results
524}
525
526pub fn extract_microdata(document: &Html) -> Vec<StructuredData> {
528 let mut results = Vec::new();
529 if let Some(sel) = try_parse_selector("*[itemscope]") {
530 for el in document.select(&sel) {
531 let itemtype = el.value().attr("itemtype")
532 .and_then(|t| t.rsplit('/').next())
533 .or_else(|| el.value().attr("itemtype"))
534 .map(|t| t.to_string());
535
536 let mut sd = match itemtype {
537 Some(ref t) => StructuredData::microdata(t),
538 None => continue,
539 };
540
541 let mut props = HashMap::new();
542 extract_microdata_properties(&el, &mut props);
543 sd.properties = props;
544 results.push(sd);
545 }
546 }
547 results
548}
549
550fn extract_microdata_properties(element: &ElementRef, props: &mut HashMap<String, serde_json::Value>) {
552 if let Some(sel) = try_parse_selector("*[itemprop]") {
553 for child in element.select(&sel) {
554 if child.value().attr("itemscope").is_some() {
555 continue;
556 }
557 if let Some(prop_name) = child.value().attr("itemprop") {
558 let value = get_microdata_value(&child);
559 props.insert(prop_name.to_string(), serde_json::Value::String(value));
560 }
561 }
562 }
563}
564
565fn get_microdata_value(element: &ElementRef) -> String {
567 if let Some(content) = element.value().attr("content") {
568 return content.trim().to_string();
569 }
570 if let Some(src) = element.value().attr("src") {
571 return src.trim().to_string();
572 }
573 if let Some(href) = element.value().attr("href") {
574 return href.trim().to_string();
575 }
576 element.text().collect::<Vec<_>>().join("").trim().to_string()
577}
578
579pub fn extract_custom_meta(document: &Html) -> HashMap<String, String> {
585 let mut custom = HashMap::new();
586 let meta_sel = &SELECTORS.meta;
587 let skip_names = [
588 "description", "keywords", "author", "generator", "viewport",
589 "theme-color", "robots", "googlebot", "date",
590 ];
591 let skip_prefixes = ["og:", "twitter:", "article:", "al:"];
592 for el in document.select(meta_sel) {
593 let name = el.value().attr("name")
594 .or_else(|| el.value().attr("property"))
595 .map(|n| n.to_string());
596 let content = el.value().attr("content")
597 .map(|c| c.to_string());
598 match (name, content) {
599 (Some(n), Some(c)) if !n.is_empty() && !c.is_empty() => {
600 let n_lower = n.to_lowercase();
601 if skip_names.contains(&n_lower.as_str()) {
602 continue;
603 }
604 if skip_prefixes.iter().any(|p| n_lower.starts_with(p)) {
605 continue;
606 }
607 custom.insert(n, c);
608 }
609 _ => {}
610 }
611 }
612 custom
613}
614
615fn resolve_url(base_url: Option<&Url>, url_str: &str) -> Option<String> {
621 let trimmed = url_str.trim();
622 if trimmed.is_empty() || trimmed.starts_with("data:") || trimmed.starts_with("javascript:") {
623 return None;
624 }
625 match base_url {
626 Some(base) => base.join(trimmed).ok().map(|u| u.to_string()),
627 None => Some(trimmed.to_string()),
628 }
629}
630
631fn find_largest_icon(candidates: &[(String, String)]) -> String {
633 let mut best: Option<&str> = None;
634 let mut best_size: i32 = -1;
635
636 for (href, sizes) in candidates {
637 if let Some(dim) = parse_icon_size(sizes) {
638 if dim > best_size {
639 best_size = dim;
640 best = Some(href);
641 }
642 } else if best.is_none() {
643 best = Some(href);
644 }
645 }
646
647 best.unwrap_or_else(|| candidates[0].0.as_str()).to_string()
648}
649
650fn parse_icon_size(sizes: &str) -> Option<i32> {
652 let sizes = sizes.trim();
653 if sizes.is_empty() || sizes.eq_ignore_ascii_case("any") {
654 return None;
655 }
656 sizes.split_whitespace()
657 .filter_map(|s| {
658 let parts: Vec<&str> = s.split('x').collect();
659 if parts.len() == 2 {
660 let w = parts[0].parse::<i32>().ok()?;
661 let h = parts[1].parse::<i32>().ok()?;
662 Some(w.max(h))
663 } else {
664 None
665 }
666 })
667 .max()
668}
669
670#[cfg(test)]
675mod tests {
676 use super::*;
677
678 fn parse_html(html: &str) -> Html {
680 Html::parse_document(html)
681 }
682
683 fn test_base_url() -> Url {
685 Url::parse("https://example.com").unwrap()
686 }
687
688 #[test]
693 fn 提取标题_og_title优先() {
694 let html = parse_html(r#"
695 <html><head>
696 <meta property="og:title" content="OG Title">
697 <title>Page Title</title>
698 </head></html>
699 "#);
700 assert_eq!(extract_title(&html), Some("OG Title".to_string()));
701 }
702
703 #[test]
704 fn 提取标题_twitter_title其次() {
705 let html = parse_html(r#"
706 <html><head>
707 <meta name="twitter:title" content="Twitter Title">
708 <title>Page Title</title>
709 </head></html>
710 "#);
711 assert_eq!(extract_title(&html), Some("Twitter Title".to_string()));
712 }
713
714 #[test]
715 fn 提取标题_title标签兜底() {
716 let html = parse_html("<html><head><title>Page Title</title></head></html>");
717 assert_eq!(extract_title(&html), Some("Page Title".to_string()));
718 }
719
720 #[test]
721 fn 提取标题_h1最后兜底() {
722 let html = parse_html("<html><body><h1>H1 Title</h1></body></html>");
723 assert_eq!(extract_title(&html), Some("H1 Title".to_string()));
724 }
725
726 #[test]
727 fn 提取标题_无标题返回None() {
728 let html = parse_html("<html><head></head><body><p>no title</p></body></html>");
729 assert_eq!(extract_title(&html), None);
730 }
731
732 #[test]
737 fn 提取charset_meta标签() {
738 let html = parse_html(r#"<html><head><meta charset="utf-8"></head></html>"#);
739 assert_eq!(extract_charset(&html), Some("utf-8".to_string()));
740 }
741
742 #[test]
743 fn 提取charset_http_equiv() {
744 let html = parse_html(r#"<html><head><meta http-equiv="Content-Type" content="text/html; charset=gb2312"></head></html>"#);
745 assert_eq!(extract_charset(&html), Some("gb2312".to_string()));
746 }
747
748 #[test]
749 fn 提取charset_无声明返回None() {
750 let html = parse_html("<html><head></head></html>");
751 assert_eq!(extract_charset(&html), None);
752 }
753
754 #[test]
759 fn 提取语言_html_lang属性() {
760 let html = parse_html(r#"<html lang="en"><head></head></html>"#);
761 assert_eq!(extract_language(&html), Some("en".to_string()));
762 }
763
764 #[test]
765 fn 提取语言_http_equiv() {
766 let html = parse_html(r#"<html><head><meta http-equiv="content-language" content="zh-CN"></head></html>"#);
767 assert_eq!(extract_language(&html), Some("zh-CN".to_string()));
768 }
769
770 #[test]
775 fn 提取base_url() {
776 let html = parse_html(r#"<html><head><base href="https://example.com/base/"></head></html>"#);
777 assert_eq!(extract_base_url(&html), Some("https://example.com/base/".to_string()));
778 }
779
780 #[test]
785 fn 提取关键词_逗号分隔() {
786 let html = parse_html(r#"<html><head><meta name="keywords" content="rust, web, scraping"></head></html>"#);
787 assert_eq!(extract_keywords(&html), vec!["rust", "web", "scraping"]);
788 }
789
790 #[test]
791 fn 提取关键词_无关键词返回空() {
792 let html = parse_html("<html><head></head></html>");
793 assert!(extract_keywords(&html).is_empty());
794 }
795
796 #[test]
801 fn 提取canonical_绝对路径() {
802 let html = parse_html(r#"<html><head><link rel="canonical" href="https://example.com/page"></head></html>"#);
803 assert_eq!(
804 extract_canonical(&html, None),
805 Some("https://example.com/page".to_string())
806 );
807 }
808
809 #[test]
810 fn 提取canonical_相对路径() {
811 let html = parse_html(r#"<html><head><link rel="canonical" href="/blog/post"></head></html>"#);
812 let base = test_base_url();
813 assert_eq!(
814 extract_canonical(&html, Some(&base)),
815 Some("https://example.com/blog/post".to_string())
816 );
817 }
818
819 #[test]
824 fn 提取favicon() {
825 let html = parse_html(r#"<html><head><link rel="icon" href="/favicon.ico"></head></html>"#);
826 let base = test_base_url();
827 assert_eq!(
828 extract_favicon(&html, Some(&base)),
829 Some("https://example.com/favicon.ico".to_string())
830 );
831 }
832
833 #[test]
834 fn 提取favicon_选最大尺寸() {
835 let html = parse_html(r#"
836 <html><head>
837 <link rel="icon" href="/small.png" sizes="16x16">
838 <link rel="icon" href="/large.png" sizes="64x64">
839 </head></html>
840 "#);
841 let base = test_base_url();
842 assert_eq!(
843 extract_favicon(&html, Some(&base)),
844 Some("https://example.com/large.png".to_string())
845 );
846 }
847
848 #[test]
853 fn 提取apple_touch_icon() {
854 let html = parse_html(r#"<html><head><link rel="apple-touch-icon" href="/apple-icon.png"></head></html>"#);
855 let base = test_base_url();
856 assert_eq!(
857 extract_apple_touch_icon(&html, Some(&base)),
858 Some("https://example.com/apple-icon.png".to_string())
859 );
860 }
861
862 #[test]
867 fn 提取robots_允许全部() {
868 let html = parse_html(r#"<html><head><meta name="robots" content="all"></head></html>"#);
869 let robots = extract_robots(&html);
870 assert!(robots.index);
871 assert!(robots.follow);
872 }
873
874 #[test]
875 fn 提取robots_noindex_nofollow() {
876 let html = parse_html(r#"<html><head><meta name="robots" content="noindex, nofollow"></head></html>"#);
877 let robots = extract_robots(&html);
878 assert!(!robots.index);
879 assert!(!robots.follow);
880 }
881
882 #[test]
883 fn 提取robots_无标签默认允许() {
884 let html = parse_html("<html><head></head></html>");
885 let robots = extract_robots(&html);
886 assert!(robots.index);
887 assert!(robots.follow);
888 }
889
890 #[test]
891 fn 提取robots_max_snippet() {
892 let html = parse_html(r#"<html><head><meta name="robots" content="max-snippet:50"></head></html>"#);
893 let robots = extract_robots(&html);
894 assert_eq!(robots.max_snippet, 50);
895 }
896
897 #[test]
902 fn 提取opengraph_基本字段() {
903 let html = parse_html(r#"
904 <html><head>
905 <meta property="og:title" content="OG Title">
906 <meta property="og:description" content="OG Description">
907 <meta property="og:url" content="https://example.com">
908 <meta property="og:type" content="article">
909 </head></html>
910 "#);
911 let og = extract_opengraph(&html);
912 assert_eq!(og.title, Some("OG Title".to_string()));
913 assert_eq!(og.description, Some("OG Description".to_string()));
914 assert_eq!(og.url, Some("https://example.com".to_string()));
915 assert_eq!(og.og_type, Some("article".to_string()));
916 assert!(og.is_present());
917 }
918
919 #[test]
920 fn 提取opengraph_扩展字段() {
921 let html = parse_html(r#"
922 <html><head>
923 <meta property="og:title" content="Title">
924 <meta property="og:custom" content="value">
925 </head></html>
926 "#);
927 let og = extract_opengraph(&html);
928 assert_eq!(og.extra.get("og:custom"), Some(&"value".to_string()));
929 }
930
931 #[test]
936 fn 提取twitter_card_基本字段() {
937 let html = parse_html(r#"
938 <html><head>
939 <meta name="twitter:card" content="summary_large_image">
940 <meta name="twitter:site" content="@example">
941 <meta name="twitter:title" content="Tweet Title">
942 </head></html>
943 "#);
944 let card = extract_twitter_card(&html);
945 assert_eq!(card.card, Some("summary_large_image".to_string()));
946 assert_eq!(card.site, Some("@example".to_string()));
947 assert_eq!(card.title, Some("Tweet Title".to_string()));
948 }
949
950 #[test]
955 fn 提取alternates() {
956 let html = parse_html(r#"
957 <html><head>
958 <link rel="alternate" hreflang="zh" href="/zh/">
959 <link rel="alternate" hreflang="en" href="/en/">
960 </head></html>
961 "#);
962 let base = test_base_url();
963 let alts = extract_alternates(&html, Some(&base));
964 assert_eq!(alts.len(), 2);
965 assert_eq!(alts[0].hreflang, "zh");
966 assert_eq!(alts[0].href, "https://example.com/zh/");
967 assert_eq!(alts[1].hreflang, "en");
968 }
969
970 #[test]
975 fn 提取json_ld() {
976 let html = parse_html(r#"
977 <html><head>
978 <script type="application/ld+json">
979 {"@type": "WebPage", "name": "Test Page", "description": "A test"}
980 </script>
981 </head></html>
982 "#);
983 let data = extract_json_ld(&html);
984 assert_eq!(data.len(), 1);
985 assert_eq!(data[0].schema_type, Some("WebPage".to_string()));
986 assert!(data[0].raw_json.is_some());
987 }
988
989 #[test]
994 fn extract_metadata_完整提取() {
995 let html = parse_html(r#"
996 <html lang="zh-CN">
997 <head>
998 <meta charset="utf-8">
999 <title>完整测试页面</title>
1000 <meta name="description" content="这是一个完整的测试页面">
1001 <meta name="keywords" content="测试, 元数据, 提取">
1002 <meta name="author" content="测试作者">
1003 <meta property="og:title" content="OG 标题">
1004 <meta property="og:type" content="website">
1005 <meta name="twitter:card" content="summary">
1006 <link rel="canonical" href="/canonical">
1007 <link rel="icon" href="/favicon.ico">
1008 <link rel="alternate" hreflang="en" href="/en/">
1009 <base href="https://example.com/blog/">
1010 </head>
1011 <body></body>
1012 </html>
1013 "#);
1014 let base = Url::parse("https://example.com").ok();
1015 let meta = extract_metadata(&html, base.as_ref()).unwrap();
1016 assert_eq!(meta.title, Some("OG 标题".to_string()));
1017 assert_eq!(meta.charset, Some("utf-8".to_string()));
1018 assert_eq!(meta.language, Some("zh-CN".to_string()));
1019 assert_eq!(meta.base_url, Some("https://example.com/blog/".to_string()));
1020 assert_eq!(meta.description, Some("这是一个完整的测试页面".to_string()));
1021 assert_eq!(meta.keywords, vec!["测试", "元数据", "提取"]);
1022 assert_eq!(meta.author, Some("测试作者".to_string()));
1023 assert_eq!(meta.canonical, Some("https://example.com/canonical".to_string()));
1024 assert!(meta.favicon.unwrap().contains("favicon.ico"));
1025 assert_eq!(meta.alternates.len(), 1);
1026 }
1027
1028 #[test]
1033 fn resolve_url_相对路径() {
1034 let base = Url::parse("https://example.com/path/").ok();
1035 assert_eq!(
1036 resolve_url(base.as_ref(), "/page.html"),
1037 Some("https://example.com/page.html".to_string())
1038 );
1039 }
1040
1041 #[test]
1042 fn resolve_url_绝对路径() {
1043 let base = Url::parse("https://example.com/path/").ok();
1044 assert_eq!(
1045 resolve_url(base.as_ref(), "https://other.com/page"),
1046 Some("https://other.com/page".to_string())
1047 );
1048 }
1049
1050 #[test]
1051 fn resolve_url_无效返回None() {
1052 let base = Url::parse("https://example.com").ok();
1053 assert_eq!(resolve_url(base.as_ref(), ""), None);
1054 assert_eq!(resolve_url(base.as_ref(), "data:image/png;base64,abc"), None);
1055 assert_eq!(resolve_url(base.as_ref(), "javascript:void(0)"), None);
1056 }
1057
1058 #[test]
1059 fn find_largest_icon_选择最大() {
1060 let candidates = vec![
1061 ("/small.png".to_string(), "16x16".to_string()),
1062 ("/medium.png".to_string(), "32x32".to_string()),
1063 ("/large.png".to_string(), "64x64".to_string()),
1064 ];
1065 assert_eq!(find_largest_icon(&candidates), "/large.png");
1066 }
1067
1068 #[test]
1069 fn find_largest_icon_无尺寸时选第一个() {
1070 let candidates = vec![
1071 ("/icon.png".to_string(), "".to_string()),
1072 ("/other.png".to_string(), "any".to_string()),
1073 ];
1074 assert_eq!(find_largest_icon(&candidates), "/icon.png");
1075 }
1076
1077 #[test]
1078 fn parse_icon_size_标准格式() {
1079 assert_eq!(parse_icon_size("32x32"), Some(32));
1080 assert_eq!(parse_icon_size("64x32"), Some(64));
1081 }
1082
1083 #[test]
1084 fn parse_icon_size_any返回None() {
1085 assert_eq!(parse_icon_size("any"), None);
1086 assert_eq!(parse_icon_size(""), None);
1087 }
1088
1089 #[test]
1090 fn extract_meta_content_by_name() {
1091 let html = parse_html(r#"<html><head><meta name="generator" content="crawlkit"></head></html>"#);
1092 assert_eq!(extract_meta_content(&html, "generator"), Some("crawlkit".to_string()));
1093 }
1094
1095 #[test]
1096 fn extract_meta_content_by_property() {
1097 let html = parse_html(r#"<html><head><meta property="article:section" content="tech"></head></html>"#);
1098 assert_eq!(extract_meta_content(&html, "article:section"), Some("tech".to_string()));
1099 }
1100
1101 #[test]
1102 fn extract_meta_content_无匹配返回None() {
1103 let html = parse_html("<html><head></head></html>");
1104 assert_eq!(extract_meta_content(&html, "nonexistent"), None);
1105 }
1106
1107 #[test]
1108 fn extract_custom_meta_过滤已知字段() {
1109 let html = parse_html(r#"
1110 <html><head>
1111 <meta name="description" content="skip me">
1112 <meta name="og:title" content="skip me too">
1113 <meta name="custom-field" content="keep me">
1114 </head></html>
1115 "#);
1116 let custom = extract_custom_meta(&html);
1117 assert_eq!(custom.len(), 1);
1118 assert_eq!(custom.get("custom-field"), Some(&"keep me".to_string()));
1119 }
1120
1121 #[test]
1122 fn extract_microdata_基本提取() {
1123 let html = parse_html(r#"
1124 <html><body>
1125 <div itemscope itemtype="http://schema.org/Person">
1126 <span itemprop="name">John Doe</span>
1127 <span itemprop="email">john@example.com</span>
1128 </div>
1129 </body></html>
1130 "#);
1131 let data = extract_microdata(&html);
1132 assert_eq!(data.len(), 1);
1133 assert_eq!(data[0].schema_type, Some("Person".to_string()));
1134 assert_eq!(
1135 data[0].properties.get("name"),
1136 Some(&serde_json::Value::String("John Doe".to_string()))
1137 );
1138 }
1139
1140 #[test]
1141 fn extract_og_property_存在() {
1142 let html = parse_html(r#"<html><head><meta property="og:image" content="https://example.com/img.jpg"></head></html>"#);
1143 assert_eq!(
1144 extract_og_property(&html, "og:image"),
1145 Some("https://example.com/img.jpg".to_string())
1146 );
1147 }
1148
1149 #[test]
1150 fn extract_og_property_不存在返回None() {
1151 let html = parse_html("<html><head></head></html>");
1152 assert_eq!(extract_og_property(&html, "og:image"), None);
1153 }
1154
1155 #[test]
1156 fn extract_twitter_property_存在() {
1157 let html = parse_html(r#"<html><head><meta name="twitter:image" content="https://example.com/img.jpg"></head></html>"#);
1158 assert_eq!(
1159 extract_twitter_property(&html, "twitter:image"),
1160 Some("https://example.com/img.jpg".to_string())
1161 );
1162 }
1163
1164 #[test]
1165 fn 提取robots_高级指令() {
1166 let html = parse_html(r#"<html><head><meta name="robots" content="noindex, max-image-preview:standard, max-video-preview:30"></head></html>"#);
1167 let robots = extract_robots(&html);
1168 assert!(!robots.index);
1169 assert_eq!(robots.max_image_preview, Some("standard".to_string()));
1170 assert_eq!(robots.max_video_preview, 30);
1171 }
1172
1173 #[test]
1174 fn extract_metadata_无base_url() {
1175 let html = parse_html(r#"
1176 <html><head>
1177 <title>No Base</title>
1178 <link rel="canonical" href="/page">
1179 </head></html>
1180 "#);
1181 let meta = extract_metadata(&html, None).unwrap();
1182 assert_eq!(meta.title, Some("No Base".to_string()));
1183 }
1184
1185 #[test]
1186 fn extract_metadata_错误处理_无效选择器不崩溃() {
1187 let html = parse_html("<html><head><title>Safe</title></head></html>");
1188 let meta = extract_metadata(&html, None).unwrap();
1189 assert_eq!(meta.title, Some("Safe".to_string()));
1190 }
1191}