Skip to main content

crawlkit_parser/
feeds.rs

1//! Feed 和 Sitemap 检测模块
2//!
3//! 适配自 halldyll-parser 的 Feed/Sitemap 检测逻辑。
4//! 提供从 HTML 页面中提取 RSS、Atom、JSON Feed 以及各类 Sitemap(XML、Index、News、
5//! Image、Video、Text、Gzip)的功能。支持从 `<link>` 标签、常见路径、robots.txt
6//! 等来源检测,也提供便捷的 URL 生成和类型判定函数。
7
8use scraper::{Html, Selector};
9use serde::{Deserialize, Serialize};
10use url::Url;
11
12#[allow(unused_imports)]
13use crate::types::ParserResult;
14
15// ============================================================================
16// 常量
17// ============================================================================
18
19/// 常见的 Feed 路径列表。
20///
21/// 遍历此列表可与目标网站的域名组合,猜测可能的 Feed 地址。
22pub 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
52/// 常见的 Sitemap 路径列表。
53///
54/// 遍历此列表可与目标网站的域名组合,猜测可能的 Sitemap 地址。
55pub 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// ============================================================================
84// Feed 类型与结构
85// ============================================================================
86
87/// Feed 类型枚举。
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum FeedType {
91    /// RSS 0.91/0.92 早期版本
92    Rss,
93    /// RSS 2.0
94    Rss2,
95    /// Atom 协议
96    Atom,
97    /// JSON Feed
98    Json,
99    /// 未知格式
100    Unknown,
101}
102
103/// 表示单个 Feed 的信息。
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Feed {
106    /// Feed 类型
107    pub feed_type: FeedType,
108    /// Feed 的完整 URL
109    pub url: String,
110    /// Feed 标题(如有)
111    pub title: Option<String>,
112    /// Feed 描述(如有)
113    pub description: Option<String>,
114}
115
116/// 包装 Feed 列表的顶层结构。
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct FeedInfo {
119    /// 检测到的所有 Feed
120    pub feeds: Vec<Feed>,
121}
122
123impl FeedInfo {
124    /// 创建一个空的 FeedInfo。
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// 添加一个 Feed。
130    pub fn add_feed(&mut self, feed: Feed) {
131        self.feeds.push(feed);
132    }
133
134    /// 判断是否包含任何 Feed。
135    pub fn has_any(&self) -> bool {
136        !self.feeds.is_empty()
137    }
138
139    /// 返回所有 RSS(含 Rss/Rss2)Feed。
140    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    /// 返回所有 Atom Feed。
145    pub fn atom_feeds(&self) -> Vec<&Feed> {
146        self.feeds.iter().filter(|f| f.feed_type == FeedType::Atom).collect()
147    }
148
149    /// 返回所有 JSON Feed。
150    pub fn json_feeds(&self) -> Vec<&Feed> {
151        self.feeds.iter().filter(|f| f.feed_type == FeedType::Json).collect()
152    }
153}
154
155// ============================================================================
156// Sitemap 类型与结构
157// ============================================================================
158
159/// Sitemap 类型枚举。
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum SitemapType {
163    /// 标准 XML Sitemap
164    Xml,
165    /// Sitemap 索引文件(指向子 Sitemap)
166    Index,
167    /// Google News Sitemap
168    News,
169    /// 图片 Sitemap
170    Image,
171    /// 视频 Sitemap
172    Video,
173    /// 纯文本 Sitemap
174    Text,
175    /// Gzip 压缩的 Sitemap
176    Gzip,
177}
178
179/// Sitemap 来源枚举。
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum SitemapSource {
183    /// 从 HTML `<link>` 标签发现
184    LinkTag,
185    /// 从 robots.txt 发现
186    RobotsTxt,
187    /// 从 .well-known/ 路径发现
188    WellKnown,
189    /// 从另一个 Sitemap Index 发现
190    SitemapIndex,
191}
192
193/// 表示单个 Sitemap 的信息。
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct Sitemap {
196    /// Sitemap 的完整 URL
197    pub url: String,
198    /// Sitemap 类型
199    pub sitemap_type: SitemapType,
200    /// 发现来源
201    pub source: SitemapSource,
202}
203
204impl Sitemap {
205    /// 创建一个新的 Sitemap。
206    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
215// ============================================================================
216// URL 解析
217// ============================================================================
218
219/// 将相对路径或协议相对 URL 解析为绝对 URL。
220///
221/// 支持三种模式:
222/// - 绝对 URL(`https://...`)直接返回
223/// - 协议相对 URL(`//cdn.example.com/file`)根据 base_url 的 scheme 补全
224/// - 相对路径(`/path`、`../path`)基于 base_url 进行解析
225pub 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
244// ============================================================================
245// Feed 类型检测
246// ============================================================================
247
248/// 根据 Feed 内容的开头特征检测 Feed 类型。
249///
250/// 通过检查前 200 个字符中的标识性标签来判定:
251/// - `<rss` 且版本含 `2.0` → Rss2
252/// - `<rss` → Rss
253/// - `<feed` → Atom
254/// - `{"version":"https://jsonfeed.org/` → Json
255pub 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
277/// 根据 URL 的路径和扩展名推测 Feed 类型。
278///
279/// 规则:
280/// - `/atom`、`.atom`、`atom.xml` → Atom
281/// - `.json`、`jsonfeed` → Json
282/// - 其余默认返回 Rss2
283pub 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
297// ============================================================================
298// Sitemap 类型检测
299// ============================================================================
300
301/// 根据 Sitemap 的 URL 路径和扩展名推测 Sitemap 类型。
302///
303/// 规则:
304/// - `.txt` → Text
305/// - `.gz` 或 `.gzip` → Gzip
306/// - 路径含 `news` → News
307/// - 路径含 `image` → Image
308/// - 路径含 `video` → Video
309/// - 路径含 `index` 或 `sitemapindex` → Index
310/// - 默认 → Xml
311pub 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
341// ============================================================================
342// Feed 提取
343// ============================================================================
344
345/// 从 HTML 文档中提取完整的 Feed 信息。
346///
347/// 内部调用 `extract_link_feeds` 扫描 `<link>` 标签,
348/// 返回封装后的 `FeedInfo` 结构。
349pub 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
358/// 从 HTML 文档的 `<link>` 标签中提取 Feed 列表。
359///
360/// 扫描 `link[type="application/rss+xml"]`、
361/// `link[type="application/atom+xml"]`、
362/// `link[type="application/feed+json"]` 等标签,
363/// 提取其 `href`、`title` 等属性并解析 Feed 类型。
364pub 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
426// ============================================================================
427// Sitemap 提取
428// ============================================================================
429
430/// 从 HTML 文档的 `<link>` 标签中提取 Sitemap 列表。
431///
432/// 匹配 `link[rel="sitemap"]` 或 `link[type="application/xml"]`
433/// 且 href 含 `sitemap` 的标签。
434pub 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
476// ============================================================================
477// URL 生成
478// ============================================================================
479
480/// 根据 base URL 生成所有常见 Feed 的完整 URL 列表。
481///
482/// 将 `COMMON_FEED_PATHS` 中的每个路径与 base URL 拼接,
483/// 返回去重后的结果。
484pub 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
499/// 根据 base URL 生成所有常见 Sitemap 的完整 URL 列表。
500///
501/// 将 `COMMON_SITEMAP_PATHS` 中的每个路径与 base URL 拼接,
502/// 返回去重后的结果。
503pub 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
518// ============================================================================
519// 便捷函数
520// ============================================================================
521
522/// 判断 HTML 文档中是否包含任何 Feed 链接。
523pub fn has_feeds(document: &Html, base_url: Option<&Url>) -> bool {
524    !extract_link_feeds(document, base_url).is_empty()
525}
526
527/// 从 HTML 文档中获取第一个 RSS(含 Rss/Rss2)Feed。
528pub 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
534/// 从 HTML 文档中获取第一个 Atom Feed。
535pub 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
541/// 从 HTML 文档中获取第一个 Feed(任意类型)。
542pub fn get_feed(document: &Html, base_url: Option<&Url>) -> Option<Feed> {
543    extract_link_feeds(document, base_url).into_iter().next()
544}
545
546/// 从 HTML 文档中获取第一个 Sitemap。
547pub fn get_sitemap(document: &Html, base_url: Option<&Url>) -> Option<Sitemap> {
548    extract_sitemaps(document, base_url).into_iter().next()
549}
550
551// ============================================================================
552// 测试
553// ============================================================================
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    // 辅助:从 HTML 字符串解析文档
560    fn parse_html(html: &str) -> Html {
561        Html::parse_document(html)
562    }
563
564    // 辅助:解析 URL
565    fn url(s: &str) -> Url {
566        Url::parse(s).unwrap()
567    }
568
569    // -----------------------------------------------------------------------
570    // Feed 类型检测
571    // -----------------------------------------------------------------------
572
573    #[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    // -----------------------------------------------------------------------
609    // Feed 类型 URL 检测
610    // -----------------------------------------------------------------------
611
612    #[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    // -----------------------------------------------------------------------
632    // Sitemap 类型检测
633    // -----------------------------------------------------------------------
634
635    #[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    // -----------------------------------------------------------------------
672    // URL 解析
673    // -----------------------------------------------------------------------
674
675    #[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    // -----------------------------------------------------------------------
709    // 从 HTML 提取 Feed
710    // -----------------------------------------------------------------------
711
712    #[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        // /rss.xml 重复了,应只有 2 个
763        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    // -----------------------------------------------------------------------
786    // FeedInfo
787    // -----------------------------------------------------------------------
788
789    #[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    // -----------------------------------------------------------------------
814    // extract_feed_info 封装函数
815    // -----------------------------------------------------------------------
816
817    #[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    // -----------------------------------------------------------------------
830    // 从 HTML 提取 Sitemap
831    // -----------------------------------------------------------------------
832
833    #[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    // -----------------------------------------------------------------------
872    // URL 生成
873    // -----------------------------------------------------------------------
874
875    #[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    // -----------------------------------------------------------------------
913    // 便捷函数
914    // -----------------------------------------------------------------------
915
916    #[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        // 返回的是 atom (选择器匹配顺序)
970    }
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    // -----------------------------------------------------------------------
984    // Sitemap 结构
985    // -----------------------------------------------------------------------
986
987    #[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    // -----------------------------------------------------------------------
996    // 边界情况与安全性
997    // -----------------------------------------------------------------------
998
999    #[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        // href="http://" 在 resolve_url 中会解析失败,但作为原始值保留
1017        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        // 两个 href 解析后相同,应去重
1031        assert_eq!(feeds.len(), 1);
1032    }
1033
1034    // -----------------------------------------------------------------------
1035    // 常量完整性
1036    // -----------------------------------------------------------------------
1037
1038    #[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}