1use scraper::{Html, Selector};
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12#[allow(unused_imports)]
13use crate::types::ParserResult;
14
15pub const COMMON_FEED_PATHS: &[&str] = &[
23 "/feed/",
24 "/feed",
25 "/rss",
26 "/rss/",
27 "/atom.xml",
28 "/feed.xml",
29 "/rss.xml",
30 "/index.xml",
31 "/atom",
32 "/feeds/posts/default",
33 "/blog/feed/",
34 "/blog/atom.xml",
35 "/blog/rss.xml",
36 "/blog/feed",
37 "/blog/rss",
38 "/blog/atom",
39 "/feeds/atom.xml",
40 "/feeds/rss.xml",
41 "/feeds/",
42 "/rss/feed.xml",
43 "/rss/category/feed.xml",
44 "/news/feed/",
45 "/news/rss.xml",
46 "/news/atom.xml",
47 "/articles/feed/",
48 "/articles/rss.xml",
49 "/articles/atom.xml",
50];
51
52pub const COMMON_SITEMAP_PATHS: &[&str] = &[
56 "/sitemap.xml",
57 "/sitemap_index.xml",
58 "/sitemapindex.xml",
59 "/sitemap/",
60 "/sitemap/sitemap.xml",
61 "/sitemapindex.xml",
62 "/sitemap1.xml",
63 "/sitemap.txt",
64 "/sitemap.xml.gz",
65 "/sitemap_index.xml.gz",
66 "/sitemapindex.xml.gz",
67 "/sitemap2.xml",
68 "/sitemap-news.xml",
69 "/sitemap-news.xml.gz",
70 "/sitemap-image.xml",
71 "/sitemap-video.xml",
72 "/sitemap-mobile.xml",
73 "/robots.txt",
74 "/sitemaps/sitemap.xml",
75 "/sitemaps/",
76 "/media/sitemap.xml",
77 "/images/sitemap.xml",
78 "/video/sitemap.xml",
79 "/news/sitemap.xml",
80 "/sitemap-index.xml",
81];
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum FeedType {
91 Rss,
93 Rss2,
95 Atom,
97 Json,
99 Unknown,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Feed {
106 pub feed_type: FeedType,
108 pub url: String,
110 pub title: Option<String>,
112 pub description: Option<String>,
114}
115
116#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct FeedInfo {
119 pub feeds: Vec<Feed>,
121}
122
123impl FeedInfo {
124 pub fn new() -> Self {
126 Self::default()
127 }
128
129 pub fn add_feed(&mut self, feed: Feed) {
131 self.feeds.push(feed);
132 }
133
134 pub fn has_any(&self) -> bool {
136 !self.feeds.is_empty()
137 }
138
139 pub fn rss_feeds(&self) -> Vec<&Feed> {
141 self.feeds.iter().filter(|f| matches!(f.feed_type, FeedType::Rss | FeedType::Rss2)).collect()
142 }
143
144 pub fn atom_feeds(&self) -> Vec<&Feed> {
146 self.feeds.iter().filter(|f| f.feed_type == FeedType::Atom).collect()
147 }
148
149 pub fn json_feeds(&self) -> Vec<&Feed> {
151 self.feeds.iter().filter(|f| f.feed_type == FeedType::Json).collect()
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum SitemapType {
163 Xml,
165 Index,
167 News,
169 Image,
171 Video,
173 Text,
175 Gzip,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum SitemapSource {
183 LinkTag,
185 RobotsTxt,
187 WellKnown,
189 SitemapIndex,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct Sitemap {
196 pub url: String,
198 pub sitemap_type: SitemapType,
200 pub source: SitemapSource,
202}
203
204impl Sitemap {
205 pub fn new(url: impl Into<String>, sitemap_type: SitemapType, source: SitemapSource) -> Self {
207 Self {
208 url: url.into(),
209 sitemap_type,
210 source,
211 }
212 }
213}
214
215pub fn resolve_url(href: &str, base_url: Option<&Url>) -> Option<String> {
226 let href = href.trim();
227 if href.is_empty() {
228 return None;
229 }
230
231 if href.starts_with("http://") || href.starts_with("https://") {
232 return Url::parse(href).ok().map(|u| u.to_string());
233 }
234
235 if href.starts_with("//") {
236 let scheme = base_url.map(|u| u.scheme()).unwrap_or("https");
237 return Some(format!("{scheme}:{href}"));
238 }
239
240 let base = base_url?;
241 base.join(href).ok().map(|u| u.to_string())
242}
243
244pub fn detect_feed_type(content: &str) -> FeedType {
256 let preview = &content[..content.len().min(200)];
257
258 if let Some(rss_pos) = preview.find("<rss") {
259 let after = &preview[rss_pos..];
260 if after.contains("2.0") || after.contains("version=\"2.0\"") {
261 return FeedType::Rss2;
262 }
263 return FeedType::Rss;
264 }
265
266 if preview.contains("<feed") {
267 return FeedType::Atom;
268 }
269
270 if preview.contains("\"version\":\"https://jsonfeed.org/") {
271 return FeedType::Json;
272 }
273
274 FeedType::Unknown
275}
276
277pub fn detect_feed_type_from_url(url: &str) -> FeedType {
284 let lower = url.to_lowercase();
285
286 if lower.contains("/atom") || lower.ends_with(".atom") || lower.ends_with("atom.xml") {
287 return FeedType::Atom;
288 }
289
290 if lower.ends_with(".json") || lower.contains("jsonfeed") {
291 return FeedType::Json;
292 }
293
294 FeedType::Rss2
295}
296
297pub fn detect_sitemap_type(url: &str) -> SitemapType {
312 let lower = url.to_lowercase();
313
314 if lower.ends_with(".txt") {
315 return SitemapType::Text;
316 }
317
318 if lower.ends_with(".gz") || lower.ends_with(".gzip") {
319 return SitemapType::Gzip;
320 }
321
322 if lower.contains("news") {
323 return SitemapType::News;
324 }
325
326 if lower.contains("image") || lower.contains("images") {
327 return SitemapType::Image;
328 }
329
330 if lower.contains("video") || lower.contains("videos") {
331 return SitemapType::Video;
332 }
333
334 if lower.contains("index") || lower.contains("sitemapindex") {
335 return SitemapType::Index;
336 }
337
338 SitemapType::Xml
339}
340
341pub fn extract_feed_info(document: &Html, base_url: Option<&Url>) -> FeedInfo {
350 let feeds = extract_link_feeds(document, base_url);
351 let mut info = FeedInfo::new();
352 for feed in feeds {
353 info.add_feed(feed);
354 }
355 info
356}
357
358pub fn extract_link_feeds(document: &Html, base_url: Option<&Url>) -> Vec<Feed> {
365 let mut feeds = Vec::new();
366
367 let selectors = [
368 "link[type=\"application/rss+xml\"]",
369 "link[type=\"application/atom+xml\"]",
370 "link[type=\"application/feed+json\"]",
371 "link[rel=\"alternate\"][type=\"application/rss+xml\"]",
372 "link[rel=\"alternate\"][type=\"application/atom+xml\"]",
373 "link[rel=\"alternate\"][type=\"application/feed+json\"]",
374 "link[rel=\"alternate\"][href$=\".rss\"]",
375 "link[rel=\"alternate\"][href$=\".atom\"]",
376 "link[rel=\"alternate\"][href$=\".json\"]",
377 ];
378
379 let mut seen_urls: Vec<String> = Vec::new();
380
381 for selector_str in &selectors {
382 let Ok(selector) = Selector::parse(selector_str) else {
383 continue;
384 };
385
386 for element in document.select(&selector) {
387 let href = match element.value().attr("href") {
388 Some(h) => h.trim(),
389 None => continue,
390 };
391
392 if href.is_empty() {
393 continue;
394 }
395
396 let resolved = resolve_url(href, base_url).unwrap_or_else(|| href.to_string());
397
398 if seen_urls.contains(&resolved) {
399 continue;
400 }
401 seen_urls.push(resolved.clone());
402
403 let feed_type = if selector_str.contains("atom") || selector_str.contains(".atom") {
404 FeedType::Atom
405 } else if selector_str.contains("json") || selector_str.contains(".json") {
406 FeedType::Json
407 } else {
408 FeedType::Rss2
409 };
410
411 let title = element.value().attr("title").map(ToString::to_string);
412 let description = element.value().attr("description").map(ToString::to_string);
413
414 feeds.push(Feed {
415 feed_type,
416 url: resolved,
417 title,
418 description,
419 });
420 }
421 }
422
423 feeds
424}
425
426pub fn extract_sitemaps(document: &Html, base_url: Option<&Url>) -> Vec<Sitemap> {
435 let mut sitemaps = Vec::new();
436
437 let selectors = [
438 "link[rel=\"sitemap\"]",
439 "link[type=\"application/xml\"]",
440 "a[href*=\"sitemap\"]",
441 ];
442
443 let mut seen_urls: Vec<String> = Vec::new();
444
445 for selector_str in &selectors {
446 let Ok(selector) = Selector::parse(selector_str) else {
447 continue;
448 };
449
450 for element in document.select(&selector) {
451 let href = match element.value().attr("href") {
452 Some(h) => h.trim(),
453 None => continue,
454 };
455
456 if href.is_empty() || !href.to_lowercase().contains("sitemap") {
457 continue;
458 }
459
460 let resolved = resolve_url(href, base_url).unwrap_or_else(|| href.to_string());
461
462 if seen_urls.contains(&resolved) {
463 continue;
464 }
465 seen_urls.push(resolved.clone());
466
467 let sitemap_type = detect_sitemap_type(&resolved);
468
469 sitemaps.push(Sitemap::new(resolved, sitemap_type, SitemapSource::LinkTag));
470 }
471 }
472
473 sitemaps
474}
475
476pub fn generate_feed_urls(base_url: &Url) -> Vec<String> {
485 let mut urls: Vec<String> = Vec::new();
486 let base_str = base_url.to_string();
487 let base = base_str.trim_end_matches('/');
488
489 for path in COMMON_FEED_PATHS {
490 let full = format!("{base}{path}");
491 if !urls.contains(&full) {
492 urls.push(full);
493 }
494 }
495
496 urls
497}
498
499pub fn generate_sitemap_urls(base_url: &Url) -> Vec<String> {
504 let mut urls: Vec<String> = Vec::new();
505 let base_str = base_url.to_string();
506 let base = base_str.trim_end_matches('/');
507
508 for path in COMMON_SITEMAP_PATHS {
509 let full = format!("{base}{path}");
510 if !urls.contains(&full) {
511 urls.push(full);
512 }
513 }
514
515 urls
516}
517
518pub fn has_feeds(document: &Html, base_url: Option<&Url>) -> bool {
524 !extract_link_feeds(document, base_url).is_empty()
525}
526
527pub fn get_rss_feed(document: &Html, base_url: Option<&Url>) -> Option<Feed> {
529 extract_link_feeds(document, base_url)
530 .into_iter()
531 .find(|f| matches!(f.feed_type, FeedType::Rss | FeedType::Rss2))
532}
533
534pub fn get_atom_feed(document: &Html, base_url: Option<&Url>) -> Option<Feed> {
536 extract_link_feeds(document, base_url)
537 .into_iter()
538 .find(|f| f.feed_type == FeedType::Atom)
539}
540
541pub fn get_feed(document: &Html, base_url: Option<&Url>) -> Option<Feed> {
543 extract_link_feeds(document, base_url).into_iter().next()
544}
545
546pub fn get_sitemap(document: &Html, base_url: Option<&Url>) -> Option<Sitemap> {
548 extract_sitemaps(document, base_url).into_iter().next()
549}
550
551#[cfg(test)]
556mod tests {
557 use super::*;
558
559 fn parse_html(html: &str) -> Html {
561 Html::parse_document(html)
562 }
563
564 fn url(s: &str) -> Url {
566 Url::parse(s).unwrap()
567 }
568
569 #[test]
574 fn 检测_rss2_类型() {
575 let content = r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>"#;
576 assert_eq!(detect_feed_type(content), FeedType::Rss2);
577 }
578
579 #[test]
580 fn 检测_rss_旧版_类型() {
581 let content = r#"<?xml version="1.0"?><rss version="0.91"><channel><title>Test</title></channel></rss>"#;
582 assert_eq!(detect_feed_type(content), FeedType::Rss);
583 }
584
585 #[test]
586 fn 检测_atom_类型() {
587 let content = r#"<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Test</title></feed>"#;
588 assert_eq!(detect_feed_type(content), FeedType::Atom);
589 }
590
591 #[test]
592 fn 检测_json_feed_类型() {
593 let content = r#"{"version":"https://jsonfeed.org/version/1","title":"Test"}"#;
594 assert_eq!(detect_feed_type(content), FeedType::Json);
595 }
596
597 #[test]
598 fn 未知内容返回_unknown() {
599 let content = r#"<html><body>普通页面</body></html>"#;
600 assert_eq!(detect_feed_type(content), FeedType::Unknown);
601 }
602
603 #[test]
604 fn 空内容返回_unknown() {
605 assert_eq!(detect_feed_type(""), FeedType::Unknown);
606 }
607
608 #[test]
613 fn 从_url_检测_atom() {
614 assert_eq!(detect_feed_type_from_url("https://example.com/atom.xml"), FeedType::Atom);
615 assert_eq!(detect_feed_type_from_url("https://example.com/blog/atom"), FeedType::Atom);
616 assert_eq!(detect_feed_type_from_url("https://example.com/feed.atom"), FeedType::Atom);
617 }
618
619 #[test]
620 fn 从_url_检测_json() {
621 assert_eq!(detect_feed_type_from_url("https://example.com/feed.json"), FeedType::Json);
622 assert_eq!(detect_feed_type_from_url("https://example.com/jsonfeed"), FeedType::Json);
623 }
624
625 #[test]
626 fn 从_url_检测默认_rss2() {
627 assert_eq!(detect_feed_type_from_url("https://example.com/rss"), FeedType::Rss2);
628 assert_eq!(detect_feed_type_from_url("https://example.com/feed.xml"), FeedType::Rss2);
629 }
630
631 #[test]
636 fn 检测标准_xml_sitemap() {
637 assert_eq!(detect_sitemap_type("https://example.com/sitemap.xml"), SitemapType::Xml);
638 }
639
640 #[test]
641 fn 检测_sitemap_index() {
642 assert_eq!(detect_sitemap_type("https://example.com/sitemap_index.xml"), SitemapType::Index);
643 assert_eq!(detect_sitemap_type("https://example.com/sitemapindex.xml"), SitemapType::Index);
644 }
645
646 #[test]
647 fn 检测_news_sitemap() {
648 assert_eq!(detect_sitemap_type("https://example.com/sitemap-news.xml"), SitemapType::News);
649 }
650
651 #[test]
652 fn 检测_image_sitemap() {
653 assert_eq!(detect_sitemap_type("https://example.com/sitemap-image.xml"), SitemapType::Image);
654 }
655
656 #[test]
657 fn 检测_video_sitemap() {
658 assert_eq!(detect_sitemap_type("https://example.com/sitemap-video.xml"), SitemapType::Video);
659 }
660
661 #[test]
662 fn 检测_text_sitemap() {
663 assert_eq!(detect_sitemap_type("https://example.com/sitemap.txt"), SitemapType::Text);
664 }
665
666 #[test]
667 fn 检测_gzip_sitemap() {
668 assert_eq!(detect_sitemap_type("https://example.com/sitemap.xml.gz"), SitemapType::Gzip);
669 }
670
671 #[test]
676 fn 解析绝对_url() {
677 let base = url("https://example.com");
678 let result = resolve_url("https://other.com/page", Some(&base));
679 assert_eq!(result.as_deref(), Some("https://other.com/page"));
680 }
681
682 #[test]
683 fn 解析相对路径() {
684 let base = url("https://example.com/base/");
685 let result = resolve_url("../feed.xml", Some(&base));
686 assert_eq!(result.as_deref(), Some("https://example.com/feed.xml"));
687 }
688
689 #[test]
690 fn 解析协议相对_url() {
691 let base = url("https://example.com");
692 let result = resolve_url("//cdn.example.com/feed", Some(&base));
693 assert_eq!(result.as_deref(), Some("https://cdn.example.com/feed"));
694 }
695
696 #[test]
697 fn 解析空字符串返回_none() {
698 let base = url("https://example.com");
699 assert!(resolve_url("", Some(&base)).is_none());
700 assert!(resolve_url(" ", Some(&base)).is_none());
701 }
702
703 #[test]
704 fn 无_base_url_时相对路径返回_none() {
705 assert!(resolve_url("/feed.xml", None).is_none());
706 }
707
708 #[test]
713 fn 提取_rss_feed_从_link_标签() {
714 let html = r#"<html><head>
715 <link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
716 </head></html>"#;
717 let doc = parse_html(html);
718 let base = url("https://example.com");
719 let feeds = extract_link_feeds(&doc, Some(&base));
720 assert_eq!(feeds.len(), 1);
721 assert_eq!(feeds[0].feed_type, FeedType::Rss2);
722 assert_eq!(feeds[0].url, "https://example.com/rss.xml");
723 assert_eq!(feeds[0].title.as_deref(), Some("RSS"));
724 }
725
726 #[test]
727 fn 提取_atom_feed_从_link_标签() {
728 let html = r#"<html><head>
729 <link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
730 </head></html>"#;
731 let doc = parse_html(html);
732 let base = url("https://example.com");
733 let feeds = extract_link_feeds(&doc, Some(&base));
734 assert_eq!(feeds.len(), 1);
735 assert_eq!(feeds[0].feed_type, FeedType::Atom);
736 assert_eq!(feeds[0].url, "https://example.com/atom.xml");
737 }
738
739 #[test]
740 fn 提取_json_feed_从_link_标签() {
741 let html = r#"<html><head>
742 <link rel="alternate" type="application/feed+json" title="JSON Feed" href="/feed.json" />
743 </head></html>"#;
744 let doc = parse_html(html);
745 let base = url("https://example.com");
746 let feeds = extract_link_feeds(&doc, Some(&base));
747 assert_eq!(feeds.len(), 1);
748 assert_eq!(feeds[0].feed_type, FeedType::Json);
749 assert_eq!(feeds[0].url, "https://example.com/feed.json");
750 }
751
752 #[test]
753 fn 提取多个_feed_并去重() {
754 let html = r#"<html><head>
755 <link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
756 <link type="application/rss+xml" title="RSS 2" href="/rss.xml" />
757 <link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
758 </head></html>"#;
759 let doc = parse_html(html);
760 let base = url("https://example.com");
761 let feeds = extract_link_feeds(&doc, Some(&base));
762 assert_eq!(feeds.len(), 2);
764 }
765
766 #[test]
767 fn 无_feed_时返回空列表() {
768 let html = r#"<html><head><title>普通页面</title></head></html>"#;
769 let doc = parse_html(html);
770 let feeds = extract_link_feeds(&doc, None);
771 assert!(feeds.is_empty());
772 }
773
774 #[test]
775 fn 提取_feed_时处理无_base_url() {
776 let html = r#"<html><head>
777 <link rel="alternate" type="application/rss+xml" href="https://other.com/feed.xml" />
778 </head></html>"#;
779 let doc = parse_html(html);
780 let feeds = extract_link_feeds(&doc, None);
781 assert_eq!(feeds.len(), 1);
782 assert_eq!(feeds[0].url, "https://other.com/feed.xml");
783 }
784
785 #[test]
790 fn feed_info_结构功能() {
791 let mut info = FeedInfo::new();
792 assert!(!info.has_any());
793
794 info.add_feed(Feed {
795 feed_type: FeedType::Rss2,
796 url: "https://example.com/rss".to_string(),
797 title: None,
798 description: None,
799 });
800 info.add_feed(Feed {
801 feed_type: FeedType::Atom,
802 url: "https://example.com/atom".to_string(),
803 title: None,
804 description: None,
805 });
806
807 assert!(info.has_any());
808 assert_eq!(info.rss_feeds().len(), 1);
809 assert_eq!(info.atom_feeds().len(), 1);
810 assert!(info.json_feeds().is_empty());
811 }
812
813 #[test]
818 fn extract_feed_info_返回_feedinfo() {
819 let html = r#"<html><head>
820 <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
821 </head></html>"#;
822 let doc = parse_html(html);
823 let base = url("https://example.com");
824 let info = extract_feed_info(&doc, Some(&base));
825 assert!(info.has_any());
826 assert_eq!(info.feeds.len(), 1);
827 }
828
829 #[test]
834 fn 提取_sitemap_从_link_标签() {
835 let html = r#"<html><head>
836 <link rel="sitemap" type="application/xml" href="/sitemap.xml" />
837 </head></html>"#;
838 let doc = parse_html(html);
839 let base = url("https://example.com");
840 let sitemaps = extract_sitemaps(&doc, Some(&base));
841 assert_eq!(sitemaps.len(), 1);
842 assert_eq!(sitemaps[0].url, "https://example.com/sitemap.xml");
843 assert_eq!(sitemaps[0].sitemap_type, SitemapType::Xml);
844 assert_eq!(sitemaps[0].source, SitemapSource::LinkTag);
845 }
846
847 #[test]
848 fn 提取_sitemap_从链接文本() {
849 let html = r#"<html><body>
850 <a href="/sitemap-index.xml">Sitemap</a>
851 </body></html>"#;
852 let doc = parse_html(html);
853 let base = url("https://example.com");
854 let sitemaps = extract_sitemaps(&doc, Some(&base));
855 assert_eq!(sitemaps.len(), 1);
856 assert_eq!(sitemaps[0].url, "https://example.com/sitemap-index.xml");
857 assert_eq!(sitemaps[0].sitemap_type, SitemapType::Index);
858 }
859
860 #[test]
861 fn 提取_sitemap_时过滤掉不含_sitemap_的链接() {
862 let html = r#"<html><body>
863 <a href="/page1">普通页面</a>
864 <a href="/sitemap.xml">Sitemap</a>
865 </body></html>"#;
866 let doc = parse_html(html);
867 let sitemaps = extract_sitemaps(&doc, None);
868 assert_eq!(sitemaps.len(), 1);
869 }
870
871 #[test]
876 fn 生成常见_feed_url() {
877 let base = url("https://example.com");
878 let urls = generate_feed_urls(&base);
879 assert!(urls.contains(&"https://example.com/feed/".to_string()));
880 assert!(urls.contains(&"https://example.com/atom.xml".to_string()));
881 assert!(urls.contains(&"https://example.com/rss.xml".to_string()));
882 assert!(urls.len() >= 10);
883 }
884
885 #[test]
886 fn 生成常见_sitemap_url() {
887 let base = url("https://example.com");
888 let urls = generate_sitemap_urls(&base);
889 assert!(urls.contains(&"https://example.com/sitemap.xml".to_string()));
890 assert!(urls.contains(&"https://example.com/robots.txt".to_string()));
891 assert!(urls.len() >= 10);
892 }
893
894 #[test]
895 fn 生成_url_去重() {
896 let base = url("https://example.com");
897 let urls = generate_feed_urls(&base);
898 let mut sorted = urls.clone();
899 sorted.sort();
900 sorted.dedup();
901 assert_eq!(urls.len(), sorted.len());
902 }
903
904 #[test]
905 fn 生成_url_处理子路径_base() {
906 let base = url("https://example.com/blog");
907 let urls = generate_feed_urls(&base);
908 assert!(urls.contains(&"https://example.com/blog/feed/".to_string()));
909 assert!(urls.contains(&"https://example.com/blog/atom.xml".to_string()));
910 }
911
912 #[test]
917 fn has_feeds_检测() {
918 let html_with = r#"<html><head>
919 <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
920 </head></html>"#;
921 let doc = parse_html(html_with);
922 assert!(has_feeds(&doc, None));
923
924 let html_without = r#"<html><head><title>无 Feed</title></head></html>"#;
925 let doc = parse_html(html_without);
926 assert!(!has_feeds(&doc, None));
927 }
928
929 #[test]
930 fn get_rss_feed_返回第一个_rss() {
931 let html = r#"<html><head>
932 <link rel="alternate" type="application/atom+xml" href="/atom.xml" />
933 <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
934 </head></html>"#;
935 let doc = parse_html(html);
936 let feed = get_rss_feed(&doc, None);
937 assert!(feed.is_some());
938 assert_eq!(feed.unwrap().feed_type, FeedType::Rss2);
939 }
940
941 #[test]
942 fn 无_feed_时_get_rss_feed_返回_none() {
943 let html = r#"<html><head><title>无 Feed</title></head></html>"#;
944 let doc = parse_html(html);
945 assert!(get_rss_feed(&doc, None).is_none());
946 }
947
948 #[test]
949 fn get_atom_feed_返回第一个_atom() {
950 let html = r#"<html><head>
951 <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
952 <link rel="alternate" type="application/atom+xml" href="/atom.xml" />
953 </head></html>"#;
954 let doc = parse_html(html);
955 let feed = get_atom_feed(&doc, None);
956 assert!(feed.is_some());
957 assert_eq!(feed.unwrap().feed_type, FeedType::Atom);
958 }
959
960 #[test]
961 fn get_feed_返回第一个任意_feed() {
962 let html = r#"<html><head>
963 <link rel="alternate" type="application/atom+xml" href="/atom.xml" />
964 <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
965 </head></html>"#;
966 let doc = parse_html(html);
967 let feed = get_feed(&doc, None);
968 assert!(feed.is_some());
969 }
971
972 #[test]
973 fn get_sitemap_返回第一个_sitemap() {
974 let html = r#"<html><head>
975 <link rel="sitemap" href="/sitemap.xml" />
976 </head></html>"#;
977 let doc = parse_html(html);
978 let sitemap = get_sitemap(&doc, None);
979 assert!(sitemap.is_some());
980 assert_eq!(sitemap.unwrap().url, "/sitemap.xml");
981 }
982
983 #[test]
988 fn sitemap_new_便捷构造() {
989 let s = Sitemap::new("https://example.com/sitemap.xml", SitemapType::Xml, SitemapSource::WellKnown);
990 assert_eq!(s.url, "https://example.com/sitemap.xml");
991 assert_eq!(s.sitemap_type, SitemapType::Xml);
992 assert_eq!(s.source, SitemapSource::WellKnown);
993 }
994
995 #[test]
1000 fn 处理_href_属性缺失() {
1001 let html = r#"<html><head>
1002 <link rel="alternate" type="application/rss+xml" />
1003 </head></html>"#;
1004 let doc = parse_html(html);
1005 let feeds = extract_link_feeds(&doc, None);
1006 assert!(feeds.is_empty());
1007 }
1008
1009 #[test]
1010 fn 处理_malformed_url() {
1011 let html = r#"<html><head>
1012 <link rel="alternate" type="application/rss+xml" href="http://" />
1013 </head></html>"#;
1014 let doc = parse_html(html);
1015 let feeds = extract_link_feeds(&doc, None);
1016 assert_eq!(feeds.len(), 1);
1018 assert_eq!(feeds[0].url, "http://");
1019 }
1020
1021 #[test]
1022 fn 不重复记录相同_url() {
1023 let html = r#"<html><head>
1024 <link rel="alternate" type="application/rss+xml" href="/rss.xml" />
1025 <link rel="alternate" type="application/rss+xml" href="https://example.com/rss.xml" />
1026 </head></html>"#;
1027 let doc = parse_html(html);
1028 let base = url("https://example.com");
1029 let feeds = extract_link_feeds(&doc, Some(&base));
1030 assert_eq!(feeds.len(), 1);
1032 }
1033
1034 #[test]
1039 fn common_feed_paths_非空() {
1040 assert!(!COMMON_FEED_PATHS.is_empty());
1041 assert!(COMMON_FEED_PATHS.len() > 5);
1042 }
1043
1044 #[test]
1045 fn common_sitemap_paths_非空() {
1046 assert!(!COMMON_SITEMAP_PATHS.is_empty());
1047 assert!(COMMON_SITEMAP_PATHS.len() > 5);
1048 }
1049
1050 #[test]
1051 fn 所有_feed_path_以斜线开头() {
1052 for path in COMMON_FEED_PATHS {
1053 assert!(path.starts_with('/'), "路径 {path} 应以 / 开头");
1054 }
1055 }
1056
1057 #[test]
1058 fn 所有_sitemap_path_以斜线开头() {
1059 for path in COMMON_SITEMAP_PATHS {
1060 assert!(path.starts_with('/'), "路径 {path} 应以 / 开头");
1061 }
1062 }
1063}