Skip to main content

crawlkit_parser/
pagination.rs

1//! 分页检测与提取模块
2//!
3//! 基于 halldyll-parser 的分页逻辑改写。
4//! 提供从 HTML 文档中检测分页类型、提取分页链接、解析页码等功能,
5//! 支持数字分页、上一页/下一页、无限滚动、加载更多、游标与偏移量等多种分页模式。
6
7use regex::Regex;
8use scraper::{Html, Selector};
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use url::Url;
12
13// ============================================================================
14// 数据结构定义
15// ============================================================================
16
17/// 分页链接
18///
19/// 表示分页导航中的一个具体页面链接及其对应的页码。
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PageUrl {
22    /// 页面 URL
23    pub url: String,
24    /// 页码(从 1 开始)
25    pub page_number: u32,
26    /// 是否为当前页
27    pub is_current: bool,
28}
29
30impl PageUrl {
31    /// 创建新的分页链接
32    pub fn new(url: impl Into<String>, page_number: u32, is_current: bool) -> Self {
33        Self {
34            url: url.into(),
35            page_number,
36            is_current,
37        }
38    }
39}
40
41/// 分页类型枚举
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum PaginationType {
45    /// 数字分页(1, 2, 3, …)
46    Numbered,
47    /// 上一页 / 下一页模式
48    NextPrev,
49    /// 无限滚动
50    InfiniteScroll,
51    /// 点击「加载更多」按钮
52    LoadMore,
53    /// 游标分页(after/before cursor)
54    Cursor,
55    /// 偏移量分页(offset/limit)
56    Offset,
57    /// 无分页
58    None,
59}
60
61/// 分页信息
62///
63/// 表示从 HTML 文档中提取的完整分页导航信息。
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Pagination {
66    /// 当前页码
67    pub current_page: u32,
68    /// 总页数(部分分页模式可能无法获取)
69    pub total_pages: Option<u32>,
70    /// 上一页 URL
71    pub prev_url: Option<String>,
72    /// 下一页 URL
73    pub next_url: Option<String>,
74    /// 第一页 URL
75    pub first_url: Option<String>,
76    /// 最后一页 URL
77    pub last_url: Option<String>,
78    /// 所有分页链接列表
79    pub page_urls: Vec<PageUrl>,
80    /// 分页类型
81    pub pagination_type: PaginationType,
82    /// 是否使用无限滚动
83    pub has_infinite_scroll: bool,
84    /// 是否使用「加载更多」
85    pub has_load_more: bool,
86    /// 每页条目数(如可推断)
87    pub items_per_page: Option<u32>,
88    /// 总条目数(如可获取)
89    pub total_items: Option<u32>,
90}
91
92impl Pagination {
93    /// 创建默认的 Pagination(无分页)
94    pub fn none() -> Self {
95        Self {
96            current_page: 1,
97            total_pages: None,
98            prev_url: None,
99            next_url: None,
100            first_url: None,
101            last_url: None,
102            page_urls: Vec::new(),
103            pagination_type: PaginationType::None,
104            has_infinite_scroll: false,
105            has_load_more: false,
106            items_per_page: None,
107            total_items: None,
108        }
109    }
110
111    /// 判断该页面是否有分页导航
112    pub fn has_pagination(&self) -> bool {
113        self.pagination_type != PaginationType::None
114            || self.page_urls.len() > 1
115            || self.next_url.is_some()
116            || self.has_infinite_scroll
117            || self.has_load_more
118    }
119
120    /// 返回下一页的 URL
121    pub fn next_page_url(&self) -> Option<&str> {
122        self.next_url.as_deref()
123    }
124
125    /// 返回上一页的 URL
126    pub fn prev_page_url(&self) -> Option<&str> {
127        self.prev_url.as_deref()
128    }
129
130    /// 获取所有数字分页链接(排除上一页/下一页)
131    pub fn numbered_pages(&self) -> Vec<&PageUrl> {
132        self.page_urls
133            .iter()
134            .filter(|p| !self.is_nav_link(&p.url))
135            .collect()
136    }
137
138    /// 判断是否为导航链接(上一页/下一页/首页/末页)
139    fn is_nav_link(&self, url: &str) -> bool {
140        Some(url) == self.prev_url.as_deref()
141            || Some(url) == self.next_url.as_deref()
142            || Some(url) == self.first_url.as_deref()
143            || Some(url) == self.last_url.as_deref()
144    }
145}
146
147impl Default for Pagination {
148    fn default() -> Self {
149        Self::none()
150    }
151}
152
153// ============================================================================
154// 正则表达式模式
155// ============================================================================
156
157lazy_static::lazy_static! {
158    /// 从 URL 路径中提取页码的正则
159    static ref PAGE_IN_URL: Regex = Regex::new(
160        r"(?i)(?:page|p|pg)[/=](\d+)"
161    ).expect("PAGE_IN_URL 正则编译失败");
162
163    /// 从 URL 路径末尾匹配页码,如 `/page/2/` 或 `/page/2`
164    static ref PAGE_IN_PATH: Regex = Regex::new(
165        r"(?i)/page/(\d+)/?"
166    ).expect("PAGE_IN_PATH 正则编译失败");
167
168    /// 从查询参数中提取页码,如 `?page=2` 或 `&page=3`
169    static ref PAGE_IN_QUERY: Regex = Regex::new(
170        r"(?i)[?&](?:page|p|pg)=(\d+)"
171    ).expect("PAGE_IN_QUERY 正则编译失败");
172
173    /// 从路径末尾匹配纯数字段,如 `/2/` 或 `/2`
174    static ref NUMERIC_PATH: Regex = Regex::new(
175        r"/(\d+)/?$"
176    ).expect("NUMERIC_PATH 正则编译失败");
177
178    /// 匹配文本中的页码信息,如 "Page 1 of 10"
179    static ref PAGE_TEXT: Regex = Regex::new(
180        r"(?i)(?:page|p[g]?)\s*(\d+)\s*(?:of|/)\s*(\d+)"
181    ).expect("PAGE_TEXT 正则编译失败");
182
183    /// 匹配简单的页码标记,如 "Page 1"
184    static ref SIMPLE_PAGE_TEXT: Regex = Regex::new(
185        r"(?i)(?:page|p[g]?)\s*(\d+)"
186    ).expect("SIMPLE_PAGE_TEXT 正则编译失败");
187
188    /// 匹配总数信息,如 "共 100 条结果" 或 "100 results"
189    static ref TOTAL_ITEMS_TEXT: Regex = Regex::new(
190        r"(?i)(?:共|total|of)\s*(\d+)\s*(?:条|个|items?|results?|页)?[条个]?[\u4e00-\u9fff]*\s*$"
191    ).expect("TOTAL_ITEMS_TEXT 正则编译失败");
192
193    /// 匹配上一页文本
194    static ref PREV_TEXT: Regex = Regex::new(
195        r"(?i)^\s*(?:上一页|prev|previous|«|<|‹|←|上一页|上一頁|上一篇)\s*$"
196    ).expect("PREV_TEXT 正则编译失败");
197
198    /// 匹配下一页文本
199    static ref NEXT_TEXT: Regex = Regex::new(
200        r"(?i)^\s*(?:下一页|next|»|>|›|→|下一页|下一頁|下一篇)\s*$"
201    ).expect("NEXT_TEXT 正则编译失败");
202
203    /// 匹配加载更多文本
204    static ref LOAD_MORE_TEXT: Regex = Regex::new(
205        r"(?i)(?:load\s*more|show\s*more|view\s*more|加载更多|查看更多|展开更多)"
206    ).expect("LOAD_MORE_TEXT 正则编译失败");
207
208    /// 检测无限滚动相关属性或类名
209    static ref INFINITE_SCROLL_PATTERN: Regex = Regex::new(
210        r"(?i)(?:infinite[\s-]?scroll|infinite[\s-]?load|endless[\s-]?(?:scroll|page))"
211    ).expect("INFINITE_SCROLL_PATTERN 正则编译失败");
212
213    /// 检测偏移量分页
214    static ref OFFSET_PATTERN: Regex = Regex::new(
215        r"(?i)[?&](?:offset|start|index|from)=(\d+)"
216    ).expect("OFFSET_PATTERN 正则编译失败");
217
218    /// 检测游标分页
219    static ref CURSOR_PATTERN: Regex = Regex::new(
220        r"(?i)[?&](?:cursor|after|before)=([^&]+)"
221    ).expect("CURSOR_PATTERN 正则编译失败");
222}
223
224// ============================================================================
225// 页码提取
226// ============================================================================
227
228/// 从 URL 中提取页码。
229///
230/// 依次尝试以下策略:
231/// 1. 查询参数 `page=`、`p=`、`pg=`(不区分大小写)
232/// 2. 路径匹配 `/page/N`
233/// 3. 通用模式 `page/N` 或 `p/N`
234/// 4. 末尾数字路径如 `/articles/2/`
235///
236/// # 示例
237///
238/// ```
239/// use crawlkit_parser::pagination::extract_page_number_from_url;
240/// assert_eq!(extract_page_number_from_url("https://example.com?page=3"), Some(3));
241/// assert_eq!(extract_page_number_from_url("https://example.com/page/5"), Some(5));
242/// assert_eq!(extract_page_number_from_url("https://example.com/articles/2/"), Some(2));
243/// ```
244pub fn extract_page_number_from_url(url: &str) -> Option<u32> {
245    // 优先从查询参数匹配
246    if let Some(caps) = PAGE_IN_QUERY.captures(url)
247        && let Ok(n) = caps[1].parse::<u32>() {
248            return Some(n);
249        }
250
251    // 尝试路径格式 /page/N
252    if let Some(caps) = PAGE_IN_PATH.captures(url)
253        && let Ok(n) = caps[1].parse::<u32>() {
254            return Some(n);
255        }
256
257    // 尝试通用 page/p/pg 模式
258    if let Some(caps) = PAGE_IN_URL.captures(url)
259        && let Ok(n) = caps[1].parse::<u32>() {
260            return Some(n);
261        }
262
263    // 尝试末尾数字路径
264    if let Some(caps) = NUMERIC_PATH.captures(url)
265        && let Ok(n) = caps[1].parse::<u32>() {
266            return Some(n);
267        }
268
269    None
270}
271
272/// 从文本中提取页码信息。
273///
274/// 支持格式:`Page 1 of 10`、`Page 1`、`共 100 条结果` 等。
275/// 返回 (当前页, 总页数, 总条目数) 的三元组。
276///
277/// # 示例
278///
279/// ```
280/// use crawlkit_parser::pagination::extract_page_info_from_text;
281/// let (cur, total, items) = extract_page_info_from_text("Page 3 of 10");
282/// assert_eq!(cur, Some(3));
283/// assert_eq!(total, Some(10));
284/// ```
285pub fn extract_page_info_from_text(text: &str) -> (Option<u32>, Option<u32>, Option<u32>) {
286    // 尝试 "Page X of Y" 格式
287    if let Some(caps) = PAGE_TEXT.captures(text) {
288        let current = caps[1].parse::<u32>().ok();
289        let total = caps[2].parse::<u32>().ok();
290        return (current, total, None);
291    }
292
293    // 尝试简单页码
294    let current = SIMPLE_PAGE_TEXT.captures(text)
295        .and_then(|caps| caps[1].parse::<u32>().ok());
296
297    // 尝试总条目数
298    let total_items = TOTAL_ITEMS_TEXT.captures(text)
299        .and_then(|caps| caps[1].parse::<u32>().ok());
300
301    (current, None, total_items)
302}
303
304// ============================================================================
305// URL 解析与链接提取
306// ============================================================================
307
308/// 解析 URL,支持相对路径和协议相对 URL。
309///
310/// 使用已有的 `resolve_url` 逻辑,已重写以避免跨模块依赖。
311pub fn resolve_url(href: &str, base_url: Option<&Url>) -> Option<String> {
312    let href = href.trim();
313    if href.is_empty() {
314        return None;
315    }
316
317    // 协议相对 URL
318    if href.starts_with("//") {
319        let scheme = base_url.map_or("https", url::Url::scheme);
320        return Some(format!("{scheme}:{href}"));
321    }
322
323    // 已经是绝对 URL
324    if href.starts_with("http://") || href.starts_with("https://") {
325        return Url::parse(href).ok().map(|u| u.to_string());
326    }
327
328    // 相对 URL
329    match base_url {
330        Some(base) => base.join(href).ok().map(|u| u.to_string()),
331        None => Some(href.to_string()),
332    }
333}
334
335/// 从 `<link rel="next">`、`<link rel="prev">` 等标签中提取分页链接。
336///
337/// 检查 HTML `<head>` 中的 `<link>` 标签,提取 `rel="next"`、`rel="prev"`、
338/// `rel="first"`、`rel="last"` 等分页信息。
339pub fn extract_rel_links(document: &Html, base_url: Option<&Url>) -> Pagination {
340    let selector = Selector::parse("link[rel][href]").expect("选择器 link[rel][href] 应合法");
341    let mut pagination = Pagination::none();
342
343    for element in document.select(&selector) {
344        let rel = element.value().attr("rel").unwrap_or("").to_lowercase();
345        let href = match element.value().attr("href") {
346            Some(h) => resolve_url(h, base_url).unwrap_or_else(|| h.to_string()),
347            None => continue,
348        };
349
350        match rel.as_str() {
351            "next" => pagination.next_url = Some(href),
352            "prev" | "previous" => pagination.prev_url = Some(href),
353            "first" => pagination.first_url = Some(href),
354            "last" => pagination.last_url = Some(href),
355            _ => {}
356        }
357    }
358
359    // 如果有 rel="next" 或 rel="prev",标记为 NextPrev 类型
360    if pagination.next_url.is_some() || pagination.prev_url.is_some() {
361        pagination.pagination_type = PaginationType::NextPrev;
362    }
363
364    pagination
365}
366
367/// 从 DOM 中提取分页链接元素。
368///
369/// 使用常见的选择器匹配分页导航中的 `<a>` 标签,
370/// 解析每个链接的 URL、页码和当前页状态。
371pub fn extract_page_links(document: &Html, base_url: Option<&Url>) -> Vec<PageUrl> {
372    // 常见分页导航选择器
373    let selectors = [
374        ".pagination a",
375        ".pager a",
376        ".page-nav a",
377        ".page-navigation a",
378        ".pages a",
379        ".page-numbers",
380        "nav.pagination a",
381        "ul.pagination a",
382        "div.pagination a",
383        "[class*=\"pagination\"] a",
384        "[class*=\"pager\"] a",
385        "[class*=\"page-nav\"] a",
386    ];
387
388    let mut page_urls = Vec::new();
389    let mut seen = HashSet::new();
390
391    for selector_str in &selectors {
392        let Ok(selector) = Selector::parse(selector_str) else {
393            continue;
394        };
395
396        for element in document.select(&selector) {
397            let href = match element.value().attr("href") {
398                Some(h) => h.trim(),
399                None => continue,
400            };
401
402            if href.is_empty() || href.starts_with('#') || href.starts_with("javascript:") {
403                continue;
404            }
405
406            let resolved = resolve_url(href, base_url);
407            let url_str = resolved.as_deref().unwrap_or(href).to_string();
408
409            // 去重
410            if !seen.insert(url_str.clone()) {
411                continue;
412            }
413
414            // 判断是否为当前页
415            let is_current = element.value().attr("class")
416                .is_some_and(|c| c.contains("current") || c.contains("active"));
417
418            // 尝试从链接文本提取页码
419            let text: String = element.text().collect::<Vec<_>>().join(" ").trim().to_string();
420            let page_number =
421                // 优先从 URL 提取
422                extract_page_number_from_url(&url_str)
423                // 其次从文本提取
424                .or_else(|| {
425                    SIMPLE_PAGE_TEXT.captures(&text)
426                        .and_then(|caps| caps[1].parse::<u32>().ok())
427                })
428                // 最后尝试直接解析文本为数字
429                .or_else(|| text.parse::<u32>().ok())
430                // 默认为 0(表示无法确定)
431                .unwrap_or(0);
432
433            page_urls.push(PageUrl {
434                url: url_str,
435                page_number,
436                is_current,
437            });
438        }
439    }
440
441    page_urls
442}
443
444// ============================================================================
445// 分页模式检测
446// ============================================================================
447
448/// 检测页面是否使用无限滚动。
449///
450/// 通过检查 JavaScript 属性、类名、data 属性以及常见无限滚动库的标记来判断。
451pub fn detect_infinite_scroll(document: &Html) -> bool {
452    // 检查特定类名或属性
453    let attr_selectors = [
454        "[class*=\"infinite-scroll\"]",
455        "[class*=\"infinite-scroll-container\"]",
456        "[id*=\"infinite-scroll\"]",
457        "[data-infinite-scroll]",
458        "[data-infinite]",
459        "[class*=\"endless-scroll\"]",
460    ];
461
462    for selector_str in &attr_selectors {
463        if let Ok(selector) = Selector::parse(selector_str)
464            && document.select(&selector).next().is_some() {
465                return true;
466            }
467    }
468
469    // 检查 `<script>` 内容
470    let script_selector = Selector::parse("script").expect("script 选择器应合法");
471    for element in document.select(&script_selector) {
472        let content: String = element.text().collect();
473        if INFINITE_SCROLL_PATTERN.is_match(&content) {
474            return true;
475        }
476    }
477
478    false
479}
480
481/// 检测页面是否使用「加载更多」按钮。
482///
483/// 通过检查常见 CSS 类名、按钮文本以及 data 属性来判断。
484pub fn detect_load_more(document: &Html) -> bool {
485    let selectors = [
486        ".load-more",
487        ".loadmore",
488        ".show-more",
489        ".view-more",
490        "[class*=\"load-more\"]",
491        "[class*=\"loadmore\"]",
492        "[class*=\"show-more\"]",
493        "[data-load-more]",
494        "[data-loadmore]",
495        "button.load-more",
496        "a.load-more",
497        "button.show-more",
498        "a.show-more",
499    ];
500
501    for selector_str in &selectors {
502        if let Ok(selector) = Selector::parse(selector_str) {
503            for element in document.select(&selector) {
504                let text: String = element.text().collect();
505                if LOAD_MORE_TEXT.is_match(&text) {
506                    return true;
507                }
508            }
509        }
510    }
511
512    false
513}
514
515/// 综合判断分页类型。
516///
517/// 基于提取到的分页链接、DOM 属性、文本内容等综合判断分页模式。
518pub fn determine_pagination_type(
519    page_urls: &[PageUrl],
520    has_infinite_scroll: bool,
521    has_load_more: bool,
522    document: &Html,
523) -> PaginationType {
524    if has_infinite_scroll {
525        return PaginationType::InfiniteScroll;
526    }
527
528    if has_load_more {
529        return PaginationType::LoadMore;
530    }
531
532    // 检查 URL 中是否包含游标参数
533    let all_urls: Vec<&str> = page_urls.iter().map(|p| p.url.as_str()).collect();
534    if all_urls.iter().any(|u| CURSOR_PATTERN.is_match(u)) {
535        return PaginationType::Cursor;
536    }
537
538    // 检查 URL 中是否包含偏移量参数
539    if all_urls.iter().any(|u| OFFSET_PATTERN.is_match(u)) {
540        return PaginationType::Offset;
541    }
542
543    // 检查是否有超过两个带明确页码的链接
544    let numbered_count = page_urls.iter().filter(|p| p.page_number > 0).count();
545    if numbered_count >= 2 {
546        return PaginationType::Numbered;
547    }
548
549    // 检查页面中是否有上一页/下一页文本标记
550    let text_selector = Selector::parse("a, span, button").expect("选择器 a, span, button 应合法");
551    let mut has_prev = false;
552    let mut has_next = false;
553
554    for element in document.select(&text_selector) {
555        let text: String = element.text().collect();
556        if PREV_TEXT.is_match(&text) {
557            has_prev = true;
558        }
559        if NEXT_TEXT.is_match(&text) {
560            has_next = true;
561        }
562        if has_prev && has_next {
563            return PaginationType::NextPrev;
564        }
565    }
566
567    PaginationType::None
568}
569
570// ============================================================================
571// 主提取函数
572// ============================================================================
573
574/// 从 HTML 文档中提取完整的分页信息。
575///
576/// 综合使用 rel 链接、DOM 分页链接、文本内容检测、无限滚动/加载更多检测等手段,
577/// 返回包含所有分页细节的 `Pagination` 结构。
578///
579/// # 参数
580///
581/// * `document` - 解析后的 HTML 文档
582/// * `base_url` - 基准 URL,用于解析相对链接
583/// * `html_content` - 原始 HTML 字符串(用于脚本内容检测)
584///
585/// # 示例
586///
587/// ```
588/// use scraper::Html;
589/// use crawlkit_parser::pagination::extract_pagination;
590///
591/// let html = r#"<html><body>
592///     <div class="pagination">
593///         <a href="?page=1" class="current">1</a>
594///         <a href="?page=2">2</a>
595///         <a href="?page=3">3</a>
596///     </div>
597/// </body></html>"#;
598/// let document = Html::parse_document(html);
599/// let pagination = extract_pagination(&document, None, html);
600/// assert!(pagination.has_pagination());
601/// assert_eq!(pagination.page_urls.len(), 3);
602/// ```
603pub fn extract_pagination(
604    document: &Html,
605    base_url: Option<&Url>,
606    _html_content: &str,
607) -> Pagination {
608    let mut pagination = Pagination::none();
609
610    // 1. 从 <link rel> 标签提取分页链接
611    let rel_pagination = extract_rel_links(document, base_url);
612    pagination.next_url = rel_pagination.next_url;
613    pagination.prev_url = rel_pagination.prev_url;
614    pagination.first_url = rel_pagination.first_url;
615    pagination.last_url = rel_pagination.last_url;
616
617    // 2. 从 DOM 提取分页链接列表
618    let page_urls = extract_page_links(document, base_url);
619    pagination.page_urls = page_urls;
620
621    // 3. 尝试从分页链接中推断当前页码
622    let current_from_urls = pagination.page_urls.iter()
623        .find(|p| p.is_current)
624        .map(|p| p.page_number);
625
626    let current_from_next = pagination.next_url.as_deref()
627        .and_then(extract_page_number_from_url)
628        .map(|n| n.saturating_sub(1));
629
630    // 尝试从 rel prev 推断当前页
631    let current_from_prev = pagination.prev_url.as_deref()
632        .and_then(extract_page_number_from_url)
633        .map(|n| n.saturating_add(1));
634
635    pagination.current_page = current_from_urls
636        .or(current_from_prev)
637        .or(current_from_next)
638        .unwrap_or(1);
639
640    // 4. 检测无限滚动
641    pagination.has_infinite_scroll = detect_infinite_scroll(document);
642
643    // 5. 检测加载更多
644    pagination.has_load_more = detect_load_more(document);
645
646    // 6. 综合判断分页类型
647    pagination.pagination_type = determine_pagination_type(
648        &pagination.page_urls,
649        pagination.has_infinite_scroll,
650        pagination.has_load_more,
651        document,
652    );
653
654    // 7. 尝试从文本提取总页数等信息
655    let text_selector = Selector::parse("body").expect("body 选择器应合法");
656    if let Some(body) = document.select(&text_selector).next() {
657        let body_text: String = body.text().collect();
658        let (cur, total, items) = extract_page_info_from_text(&body_text);
659        if pagination.current_page == 1 && cur.is_some() {
660            pagination.current_page = cur.unwrap_or(1);
661        }
662        pagination.total_pages = pagination.total_pages.or(total);
663        pagination.total_items = pagination.total_items.or(items);
664    }
665
666    // 8. 从 URL 查询参数推断总页数(如果有 total 参数)
667    if let Some(base) = base_url
668        && let Some(total_str) = base.query_pairs()
669            .find(|(k, _)| k == "total" || k == "pages")
670            .map(|(_, v)| v.to_string())
671            && let Ok(total) = total_str.parse::<u32>() {
672                pagination.total_pages = Some(total);
673            }
674
675    pagination
676}
677
678// ============================================================================
679// 便捷函数
680// ============================================================================
681
682/// 快速判断 HTML 文档中是否包含分页。
683///
684/// 检查 rel 链接、分页 DOM 元素、无限滚动标记、加载更多按钮等。
685///
686/// # 示例
687///
688/// ```
689/// use scraper::Html;
690/// use crawlkit_parser::pagination::has_pagination;
691///
692/// let html = r#"<html><body><div class="pagination"><a href="?page=2">2</a></div></body></html>"#;
693/// let document = Html::parse_document(html);
694/// assert!(has_pagination(&document, html));
695/// ```
696pub fn has_pagination(document: &Html, _html_content: &str) -> bool {
697    // 检查 rel 链接
698    let rel_selector = Selector::parse("link[rel=\"next\"], link[rel=\"prev\"]")
699        .expect("选择器应合法");
700    if document.select(&rel_selector).next().is_some() {
701        return true;
702    }
703
704    // 检查分页 DOM 元素
705    let pagination_classes = [
706        ".pagination",
707        ".pager",
708        ".page-nav",
709        ".page-numbers",
710        "[class*=\"pagination\"]",
711    ];
712    let any_pagination = pagination_classes.iter().any(|cls| {
713        Selector::parse(cls)
714            .ok()
715            .is_some_and(|sel| document.select(&sel).next().is_some())
716    });
717    if any_pagination {
718        return true;
719    }
720
721    // 检查无限滚动
722    if detect_infinite_scroll(document) {
723        return true;
724    }
725
726    // 检查加载更多
727    if detect_load_more(document) {
728        return true;
729    }
730
731    // 检查常见分页文本
732    let body_selector = Selector::parse("body").expect("body 选择器应合法");
733    if let Some(body) = document.select(&body_selector).next() {
734        let text: String = body.text().collect();
735        if PREV_TEXT.is_match(&text) || NEXT_TEXT.is_match(&text) || PAGE_TEXT.is_match(&text) {
736            return true;
737        }
738    }
739
740    false
741}
742
743/// 获取下一页的 URL。
744///
745/// 优先从 `<link rel="next">` 获取,其次从分页 DOM 中推断。
746///
747/// # 示例
748///
749/// ```
750/// use scraper::Html;
751/// use crawlkit_parser::pagination::get_next_page;
752///
753/// let html = r#"<html><head><link rel="next" href="https://example.com?page=2"></head></html>"#;
754/// let document = Html::parse_document(html);
755/// assert_eq!(get_next_page(&document, None), Some("https://example.com/?page=2".to_string()));
756/// ```
757pub fn get_next_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
758    // 优先从 rel="next" 获取
759    if let Some(url) = extract_rel_link(document, "next", base_url) {
760        return Some(url);
761    }
762
763    // 从分页 DOM 中找带有「下一页」文本的链接
764    let selectors = [
765        "a.next",
766        "a.next-page",
767        "a[rel=\"next\"]",
768        ".pagination a:last-child",
769        ".pager a:last-child",
770    ];
771    for selector_str in &selectors {
772        let Ok(selector) = Selector::parse(selector_str) else {
773            continue;
774        };
775        for element in document.select(&selector) {
776            let text: String = element.text().collect();
777            if NEXT_TEXT.is_match(&text)
778                && let Some(href) = element.value().attr("href") {
779                    return resolve_url(href, base_url);
780                }
781        }
782    }
783
784    // 从分页链接中找出比当前页大 1 的链接
785    let page_urls = extract_page_links(document, base_url);
786    let current = page_urls.iter().find(|p| p.is_current).map(|p| p.page_number);
787    if let Some(cur) = current
788        && let Some(next) = page_urls.iter().find(|p| p.page_number == cur + 1) {
789            return Some(next.url.clone());
790        }
791
792    None
793}
794
795/// 获取上一页的 URL。
796///
797/// 优先从 `<link rel="prev">` 获取,其次从分页 DOM 中推断。
798///
799/// # 示例
800///
801/// ```
802/// use scraper::Html;
803/// use crawlkit_parser::pagination::get_prev_page;
804///
805/// let html = r#"<html><head><link rel="prev" href="https://example.com?page=1"></head></html>"#;
806/// let document = Html::parse_document(html);
807/// assert_eq!(get_prev_page(&document, None), Some("https://example.com/?page=1".to_string()));
808/// ```
809pub fn get_prev_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
810    // 优先从 rel="prev" 获取
811    if let Some(url) = extract_rel_link(document, "prev", base_url) {
812        return Some(url);
813    }
814
815    // 从分页 DOM 中找带有「上一页」文本的链接
816    let selectors = [
817        "a.prev",
818        "a.previous",
819        "a.prev-page",
820        "a[rel=\"prev\"]",
821        ".pagination a:first-child",
822        ".pager a:first-child",
823    ];
824    for selector_str in &selectors {
825        let Ok(selector) = Selector::parse(selector_str) else {
826            continue;
827        };
828        for element in document.select(&selector) {
829            let text: String = element.text().collect();
830            if PREV_TEXT.is_match(&text)
831                && let Some(href) = element.value().attr("href") {
832                    return resolve_url(href, base_url);
833                }
834        }
835    }
836
837    // 从分页链接中找出比当前页小 1 的链接
838    let page_urls = extract_page_links(document, base_url);
839    let current = page_urls.iter().find(|p| p.is_current).map(|p| p.page_number);
840    if let Some(cur) = current
841        && cur > 1
842            && let Some(prev) = page_urls.iter().find(|p| p.page_number == cur - 1) {
843                return Some(prev.url.clone());
844            }
845
846    None
847}
848
849/// 根据基准 URL 和页码生成分页 URL。
850///
851/// 检测原始 URL 中的页码模式(查询参数或路径),替换为新的页码后返回。
852///
853/// # 示例
854///
855/// ```
856/// use url::Url;
857/// use crawlkit_parser::pagination::generate_page_url;
858///
859/// let base = Url::parse("https://example.com?page=1").unwrap();
860/// assert_eq!(generate_page_url(&base, 3), Some("https://example.com/?page=3".to_string()));
861/// ```
862pub fn generate_page_url(base_url: &Url, page_num: u32) -> Option<String> {
863    // 如果 URL 已包含 page 参数,替换它
864    if PAGE_IN_QUERY.is_match(base_url.as_str()) {
865        let result = PAGE_IN_QUERY.replace(base_url.as_str(), |caps: &regex::Captures| {
866            // 保留前缀(? 或 &)和参数名,只替换值
867            let prefix = &caps[0][..caps[0].len() - caps[1].len()];
868            format!("{prefix}{page_num}")
869        });
870        return Some(result.to_string());
871    }
872
873    // 如果 URL 路径中包含 /page/N,替换之
874    if PAGE_IN_PATH.is_match(base_url.as_str()) {
875        let result = PAGE_IN_PATH.replace(base_url.as_str(), |caps: &regex::Captures| {
876            let prefix = &caps[0][..caps[0].len() - caps[1].len()];
877            format!("{prefix}{page_num}")
878        });
879        return Some(result.to_string());
880    }
881
882    // 否则拼接 page 查询参数
883    let mut url = base_url.clone();
884    url.query_pairs_mut().append_pair("page", &page_num.to_string());
885    Some(url.to_string())
886}
887
888// ============================================================================
889// 内部辅助函数
890// ============================================================================
891
892/// 从 `<link rel="...">` 标签中提取指定 rel 值的 href 属性。
893fn extract_rel_link(document: &Html, rel_value: &str, base_url: Option<&Url>) -> Option<String> {
894    let selector_str = format!("link[rel=\"{rel_value}\"][href]");
895    let selector = Selector::parse(&selector_str).ok()?;
896    let element = document.select(&selector).next()?;
897    let href = element.value().attr("href")?;
898    resolve_url(href, base_url)
899}
900
901// ============================================================================
902// 测试
903// ============================================================================
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    // 辅助:使用 None base_url 提取分页
910    fn pagination_from_html(html: &str) -> Pagination {
911        let document = Html::parse_document(html);
912        extract_pagination(&document, None, html)
913    }
914
915    // ========================================================================
916    // 基础测试:无分页页面
917    // ========================================================================
918
919    #[test]
920    fn test_no_pagination() {
921        let html = "<html><body><p>Hello, world!</p></body></html>";
922        let pag = pagination_from_html(html);
923        assert_eq!(pag.pagination_type, PaginationType::None);
924        assert!(!pag.has_pagination());
925        assert!(pag.page_urls.is_empty());
926        assert!(pag.next_url.is_none());
927        assert!(pag.prev_url.is_none());
928        assert_eq!(pag.current_page, 1);
929    }
930
931    // ========================================================================
932    // 测试:从 URL 提取页码
933    // ========================================================================
934
935    #[test]
936    fn test_extract_page_number_from_query() {
937        assert_eq!(extract_page_number_from_url("https://example.com?page=3"), Some(3));
938        assert_eq!(extract_page_number_from_url("https://example.com?p=5"), Some(5));
939        assert_eq!(extract_page_number_from_url("https://example.com?pg=2"), Some(2));
940    }
941
942    #[test]
943    fn test_extract_page_number_from_path() {
944        assert_eq!(extract_page_number_from_url("https://example.com/page/3"), Some(3));
945        assert_eq!(extract_page_number_from_url("https://example.com/page/10/"), Some(10));
946    }
947
948    #[test]
949    fn test_extract_page_number_from_numeric_suffix() {
950        assert_eq!(extract_page_number_from_url("https://example.com/articles/42/"), Some(42));
951    }
952
953    #[test]
954    fn test_extract_page_number_no_match() {
955        assert_eq!(extract_page_number_from_url("https://example.com/about"), None);
956        assert_eq!(extract_page_number_from_url("https://example.com"), None);
957    }
958
959    #[test]
960    fn test_extract_page_number_case_insensitive() {
961        assert_eq!(extract_page_number_from_url("https://example.com?Page=7"), Some(7));
962        assert_eq!(extract_page_number_from_url("https://example.com/PAGE/2"), Some(2));
963    }
964
965    // ========================================================================
966    // 测试:从文本提取页码信息
967    // ========================================================================
968
969    #[test]
970    fn test_extract_page_info_full() {
971        let (cur, total, items) = extract_page_info_from_text("Page 3 of 10");
972        assert_eq!(cur, Some(3));
973        assert_eq!(total, Some(10));
974        assert_eq!(items, None);
975    }
976
977    #[test]
978    fn test_extract_page_info_simple() {
979        let (cur, total, items) = extract_page_info_from_text("Page 5");
980        assert_eq!(cur, Some(5));
981        assert_eq!(total, None);
982        assert_eq!(items, None);
983    }
984
985    #[test]
986    fn test_extract_page_info_total_items() {
987        let (cur, total, items) = extract_page_info_from_text("共 200 条结果");
988        assert_eq!(cur, None);
989        assert_eq!(total, None);
990        assert_eq!(items, Some(200));
991    }
992
993    #[test]
994    fn test_extract_page_info_no_match() {
995        let (cur, total, items) = extract_page_info_from_text("Hello World");
996        assert_eq!(cur, None);
997        assert_eq!(total, None);
998        assert_eq!(items, None);
999    }
1000
1001    // ========================================================================
1002    // 测试:解析 URL
1003    // ========================================================================
1004
1005    #[test]
1006    fn test_resolve_url_absolute() {
1007        let base = Url::parse("https://example.com").ok();
1008        let result = resolve_url("https://other.com/path", base.as_ref());
1009        assert_eq!(result.as_deref(), Some("https://other.com/path"));
1010    }
1011
1012    #[test]
1013    fn test_resolve_url_relative() {
1014        let base = Url::parse("https://example.com/base/").ok();
1015        let result = resolve_url("../page", base.as_ref());
1016        assert_eq!(result.as_deref(), Some("https://example.com/page"));
1017    }
1018
1019    #[test]
1020    fn test_resolve_url_protocol_relative() {
1021        let base = Url::parse("https://example.com").ok();
1022        let result = resolve_url("//other.com/path", base.as_ref());
1023        assert_eq!(result.as_deref(), Some("https://other.com/path"));
1024    }
1025
1026    #[test]
1027    fn test_resolve_url_empty() {
1028        assert!(resolve_url("", None).is_none());
1029        assert!(resolve_url("  ", None).is_none());
1030    }
1031
1032    // ========================================================================
1033    // 测试:提取 rel 链接
1034    // ========================================================================
1035
1036    #[test]
1037    fn test_extract_rel_links_next_prev() {
1038        let html = r#"<html><head>
1039            <link rel="next" href="https://example.com?page=2">
1040            <link rel="prev" href="https://example.com?page=1">
1041        </head></html>"#;
1042        let document = Html::parse_document(html);
1043        let pag = extract_rel_links(&document, None);
1044        assert_eq!(pag.next_url.as_deref(), Some("https://example.com/?page=2"));
1045        assert_eq!(pag.prev_url.as_deref(), Some("https://example.com/?page=1"));
1046        assert_eq!(pag.pagination_type, PaginationType::NextPrev);
1047    }
1048
1049    #[test]
1050    fn test_extract_rel_links_first_last() {
1051        let html = r#"<html><head>
1052            <link rel="first" href="https://example.com">
1053            <link rel="last" href="https://example.com?page=50">
1054        </head></html>"#;
1055        let document = Html::parse_document(html);
1056        let pag = extract_rel_links(&document, None);
1057        assert_eq!(pag.first_url.as_deref(), Some("https://example.com/"));
1058        assert_eq!(pag.last_url.as_deref(), Some("https://example.com/?page=50"));
1059    }
1060
1061    // ========================================================================
1062    // 测试:提取分页链接
1063    // ========================================================================
1064
1065    #[test]
1066    fn test_extract_page_links_numbered() {
1067        let html = r#"<html><body>
1068            <div class="pagination">
1069                <a href="?page=1" class="current">1</a>
1070                <a href="?page=2">2</a>
1071                <a href="?page=3">3</a>
1072            </div>
1073        </body></html>"#;
1074        let document = Html::parse_document(html);
1075        let links = extract_page_links(&document, None);
1076        assert_eq!(links.len(), 3);
1077        assert!(links[0].is_current);
1078        assert_eq!(links[0].page_number, 1);
1079        assert!(!links[1].is_current);
1080        assert_eq!(links[1].page_number, 2);
1081        assert_eq!(links[2].page_number, 3);
1082    }
1083
1084    #[test]
1085    fn test_extract_page_links_with_text_numbers() {
1086        let html = r#"<html><body>
1087            <ul class="pagination">
1088                <li><a href="/page/1">1</a></li>
1089                <li><a href="/page/2" class="active">2</a></li>
1090                <li><a href="/page/3">3</a></li>
1091            </ul>
1092        </body></html>"#;
1093        let document = Html::parse_document(html);
1094        let links = extract_page_links(&document, None);
1095        // 链接文本可解析为数字
1096        assert_eq!(links.len(), 3);
1097        // /page/1 => 页码 1; /page/2 => 页码 2
1098        assert_eq!(links[0].page_number, 1);
1099        assert!(!links[0].is_current);
1100        assert_eq!(links[1].page_number, 2);
1101        assert!(links[1].is_current);
1102    }
1103
1104    #[test]
1105    fn test_extract_page_links_deduplicates() {
1106        let html = r#"<html><body>
1107            <div class="pagination">
1108                <a href="?page=1">1</a>
1109                <a href="?page=2">2</a>
1110            </div>
1111            <nav class="pagination">
1112                <a href="?page=1">1</a>
1113                <a href="?page=2">2</a>
1114            </nav>
1115        </body></html>"#;
1116        let document = Html::parse_document(html);
1117        let links = extract_page_links(&document, None);
1118        assert_eq!(links.len(), 2); // 去重后应为 2
1119    }
1120
1121    // ========================================================================
1122    // 测试:无限滚动检测
1123    // ========================================================================
1124
1125    #[test]
1126    fn test_detect_infinite_scroll_by_class() {
1127        let html = r#"<html><body><div class="infinite-scroll"></div></body></html>"#;
1128        let document = Html::parse_document(html);
1129        assert!(detect_infinite_scroll(&document));
1130    }
1131
1132    #[test]
1133    fn test_detect_infinite_scroll_by_data_attr() {
1134        let html = r#"<html><body><div data-infinite-scroll="true"></div></body></html>"#;
1135        let document = Html::parse_document(html);
1136        assert!(detect_infinite_scroll(&document));
1137    }
1138
1139    #[test]
1140    fn test_detect_infinite_scroll_by_script() {
1141        let html = r"<html><body><script>var infiniteScroll = true;</script></body></html>";
1142        let document = Html::parse_document(html);
1143        assert!(detect_infinite_scroll(&document));
1144    }
1145
1146    #[test]
1147    fn test_detect_infinite_scroll_none() {
1148        let html = r"<html><body><p>No scroll here</p></body></html>";
1149        let document = Html::parse_document(html);
1150        assert!(!detect_infinite_scroll(&document));
1151    }
1152
1153    // ========================================================================
1154    // 测试:加载更多检测
1155    // ========================================================================
1156
1157    #[test]
1158    fn test_detect_load_more_by_class() {
1159        let html = r#"<html><body><button class="load-more">加载更多</button></body></html>"#;
1160        let document = Html::parse_document(html);
1161        assert!(detect_load_more(&document));
1162    }
1163
1164    #[test]
1165    fn test_detect_load_more_by_text() {
1166        let html = r#"<html><body><a class="show-more">查看更多</a></body></html>"#;
1167        let document = Html::parse_document(html);
1168        assert!(detect_load_more(&document));
1169    }
1170
1171    #[test]
1172    fn test_detect_load_more_english() {
1173        let html = r#"<html><body><button class="load-more">Load More</button></body></html>"#;
1174        let document = Html::parse_document(html);
1175        assert!(detect_load_more(&document));
1176    }
1177
1178    #[test]
1179    fn test_detect_load_more_none() {
1180        let html = r"<html><body><button>提交</button></body></html>";
1181        let document = Html::parse_document(html);
1182        assert!(!detect_load_more(&document));
1183    }
1184
1185    // ========================================================================
1186    // 测试:分页类型判断
1187    // ========================================================================
1188
1189    #[test]
1190    fn test_determine_pagination_type_numbered() {
1191        let urls = vec![
1192            PageUrl::new("?page=1", 1, true),
1193            PageUrl::new("?page=2", 2, false),
1194            PageUrl::new("?page=3", 3, false),
1195        ];
1196        let document = Html::parse_document("<html></html>");
1197        let ptype = determine_pagination_type(&urls, false, false, &document);
1198        assert_eq!(ptype, PaginationType::Numbered);
1199    }
1200
1201    #[test]
1202    fn test_determine_pagination_type_infinite_scroll() {
1203        let urls = vec![];
1204        let document = Html::parse_document("<html></html>");
1205        let ptype = determine_pagination_type(&urls, true, false, &document);
1206        assert_eq!(ptype, PaginationType::InfiniteScroll);
1207    }
1208
1209    #[test]
1210    fn test_determine_pagination_type_load_more() {
1211        let urls = vec![];
1212        let document = Html::parse_document("<html></html>");
1213        let ptype = determine_pagination_type(&urls, false, true, &document);
1214        assert_eq!(ptype, PaginationType::LoadMore);
1215    }
1216
1217    #[test]
1218    fn test_determine_pagination_type_next_prev_from_text() {
1219        let urls = vec![];
1220        let html = r"<html><body><a>上一页</a><a>下一页</a></body></html>";
1221        let document = Html::parse_document(html);
1222        let ptype = determine_pagination_type(&urls, false, false, &document);
1223        assert_eq!(ptype, PaginationType::NextPrev);
1224    }
1225
1226    #[test]
1227    fn test_determine_pagination_type_cursor() {
1228        let urls = vec![
1229            PageUrl::new("https://example.com?cursor=abc", 0, false),
1230        ];
1231        let document = Html::parse_document("<html></html>");
1232        let ptype = determine_pagination_type(&urls, false, false, &document);
1233        assert_eq!(ptype, PaginationType::Cursor);
1234    }
1235
1236    #[test]
1237    fn test_determine_pagination_type_offset() {
1238        let urls = vec![
1239            PageUrl::new("https://example.com?offset=10", 0, false),
1240        ];
1241        let document = Html::parse_document("<html></html>");
1242        let ptype = determine_pagination_type(&urls, false, false, &document);
1243        assert_eq!(ptype, PaginationType::Offset);
1244    }
1245
1246    // ========================================================================
1247    // 测试:主提取函数
1248    // ========================================================================
1249
1250    #[test]
1251    fn test_extract_pagination_numbered() {
1252        let html = r#"<html><head>
1253            <link rel="next" href="?page=2">
1254        </head><body>
1255            <div class="pagination">
1256                <a href="?page=1" class="current">1</a>
1257                <a href="?page=2">2</a>
1258                <a href="?page=3">3</a>
1259            </div>
1260        </body></html>"#;
1261        let pag = pagination_from_html(html);
1262        assert_eq!(pag.pagination_type, PaginationType::Numbered);
1263        assert!(pag.has_pagination());
1264        assert_eq!(pag.page_urls.len(), 3);
1265        assert_eq!(pag.current_page, 1);
1266        assert_eq!(pag.next_url.as_deref(), Some("?page=2"));
1267    }
1268
1269    #[test]
1270    fn test_extract_pagination_with_base_url() {
1271        let html = r#"<html><body>
1272            <div class="pagination">
1273                <a href="/page/2">2</a>
1274                <a href="/page/3">3</a>
1275            </div>
1276        </body></html>"#;
1277        let base = Url::parse("https://example.com/page/1").ok();
1278        let document = Html::parse_document(html);
1279        let pag = extract_pagination(&document, base.as_ref(), html);
1280        assert!(pag.has_pagination());
1281        assert_eq!(pag.page_urls.len(), 2);
1282        assert_eq!(pag.page_urls[0].url, "https://example.com/page/2");
1283        assert_eq!(pag.page_urls[1].url, "https://example.com/page/3");
1284    }
1285
1286    #[test]
1287    fn test_extract_pagination_infinite_scroll() {
1288        let html = r#"<html><body>
1289            <div class="infinite-scroll" data-infinite-scroll="true"></div>
1290            <script>var infiniteScroll = true;</script>
1291        </body></html>"#;
1292        let pag = pagination_from_html(html);
1293        assert_eq!(pag.pagination_type, PaginationType::InfiniteScroll);
1294        assert!(pag.has_infinite_scroll);
1295        assert!(pag.has_pagination());
1296    }
1297
1298    #[test]
1299    fn test_extract_pagination_load_more() {
1300        let html = r#"<html><body>
1301            <button class="load-more">加载更多</button>
1302        </body></html>"#;
1303        let pag = pagination_from_html(html);
1304        assert_eq!(pag.pagination_type, PaginationType::LoadMore);
1305        assert!(pag.has_load_more);
1306        assert!(pag.has_pagination());
1307    }
1308
1309    // ========================================================================
1310    // 测试:便捷函数
1311    // ========================================================================
1312
1313    #[test]
1314    fn test_has_pagination_pagination_class() {
1315        let html = r#"<html><body><div class="pagination"></div></body></html>"#;
1316        let document = Html::parse_document(html);
1317        assert!(has_pagination(&document, html));
1318    }
1319
1320    #[test]
1321    fn test_has_pagination_rel_links() {
1322        let html = r#"<html><head><link rel="next" href="?page=2"></head></html>"#;
1323        let document = Html::parse_document(html);
1324        assert!(has_pagination(&document, html));
1325    }
1326
1327    #[test]
1328    fn test_has_pagination_no_pagination() {
1329        let html = "<html><body><p>No pagination</p></body></html>";
1330        let document = Html::parse_document(html);
1331        assert!(!has_pagination(&document, html));
1332    }
1333
1334    #[test]
1335    fn test_get_next_page_from_rel() {
1336        let html = r#"<html><head><link rel="next" href="https://example.com?page=2"></head></html>"#;
1337        let document = Html::parse_document(html);
1338        assert_eq!(
1339            get_next_page(&document, None),
1340            Some("https://example.com/?page=2".to_string())
1341        );
1342    }
1343
1344    #[test]
1345    fn test_get_next_page_from_dom() {
1346        let html = r#"<html><body>
1347            <div class="pagination">
1348                <a href="?page=1" class="current">1</a>
1349                <a href="?page=2">2</a>
1350                <a href="?page=3">3</a>
1351            </div>
1352        </body></html>"#;
1353        let document = Html::parse_document(html);
1354        // 会找到 page=2(比当前页大 1)
1355        let next = get_next_page(&document, None);
1356        assert!(next.is_some());
1357    }
1358
1359    #[test]
1360    fn test_get_next_page_not_found() {
1361        let html = "<html><body><p>No pagination</p></body></html>";
1362        let document = Html::parse_document(html);
1363        assert!(get_next_page(&document, None).is_none());
1364    }
1365
1366    #[test]
1367    fn test_get_prev_page_from_rel() {
1368        let html = r#"<html><head><link rel="prev" href="https://example.com?page=1"></head></html>"#;
1369        let document = Html::parse_document(html);
1370        assert_eq!(
1371            get_prev_page(&document, None),
1372            Some("https://example.com/?page=1".to_string())
1373        );
1374    }
1375
1376    #[test]
1377    fn test_generate_page_url_replace_query() {
1378        let base = Url::parse("https://example.com?page=1").unwrap();
1379        assert_eq!(
1380            generate_page_url(&base, 3),
1381            Some("https://example.com/?page=3".to_string())
1382        );
1383    }
1384
1385    #[test]
1386    fn test_generate_page_url_replace_path() {
1387        let base = Url::parse("https://example.com/page/1").unwrap();
1388        let result = generate_page_url(&base, 5);
1389        assert!(result.is_some());
1390        assert!(result.unwrap().contains("page/5"));
1391    }
1392
1393    #[test]
1394    fn test_generate_page_url_append_query() {
1395        let base = Url::parse("https://example.com/search?q=rust").unwrap();
1396        let result = generate_page_url(&base, 2).unwrap();
1397        assert!(result.contains("page=2"));
1398    }
1399
1400    // ========================================================================
1401    // 测试:Pagination 对象方法
1402    // ========================================================================
1403
1404    #[test]
1405    fn test_pagination_has_pagination_false() {
1406        let pag = Pagination::none();
1407        assert!(!pag.has_pagination());
1408    }
1409
1410    #[test]
1411    fn test_pagination_has_pagination_with_page_urls() {
1412        let mut pag = Pagination::none();
1413        pag.page_urls.push(PageUrl::new("?page=2", 2, false));
1414        pag.page_urls.push(PageUrl::new("?page=3", 3, false));
1415        assert!(pag.has_pagination());
1416    }
1417
1418    #[test]
1419    fn test_pagination_numbered_pages() {
1420        let mut pag = Pagination::none();
1421        pag.next_url = Some("?page=4".to_string());
1422        pag.prev_url = Some("?page=2".to_string());
1423        pag.page_urls = vec![
1424            PageUrl::new("?page=2", 2, false),
1425            PageUrl::new("?page=3", 3, true),
1426            PageUrl::new("?page=4", 4, false),
1427        ];
1428        let numbered = pag.numbered_pages();
1429        // page_urls 共 3 条,其中 next_url 和 prev_url 各匹配一条
1430        assert_eq!(numbered.len(), 1);
1431        assert_eq!(numbered[0].page_number, 3);
1432    }
1433
1434    #[test]
1435    fn test_pagination_none_is_default() {
1436        let pag = Pagination::default();
1437        assert_eq!(pag.pagination_type, PaginationType::None);
1438        assert_eq!(pag.current_page, 1);
1439        assert!(pag.page_urls.is_empty());
1440    }
1441
1442    // ========================================================================
1443    // 测试:边界情况
1444    // ========================================================================
1445
1446    #[test]
1447    fn test_extract_page_number_from_url_empty() {
1448        assert_eq!(extract_page_number_from_url(""), None);
1449    }
1450
1451    #[test]
1452    fn test_extract_page_links_empty_html() {
1453        let document = Html::parse_document("<html></html>");
1454        let links = extract_page_links(&document, None);
1455        assert!(links.is_empty());
1456    }
1457
1458    #[test]
1459    fn test_determine_pagination_type_empty() {
1460        let urls = vec![];
1461        let document = Html::parse_document("<html></html>");
1462        let ptype = determine_pagination_type(&urls, false, false, &document);
1463        assert_eq!(ptype, PaginationType::None);
1464    }
1465
1466    #[test]
1467    fn test_resolve_url_http_scheme() {
1468        let base = Url::parse("http://example.com").ok();
1469        let result = resolve_url("//cdn.example.com/file.js", base.as_ref());
1470        assert_eq!(result.as_deref(), Some("http://cdn.example.com/file.js"));
1471    }
1472
1473    #[test]
1474    fn test_extract_page_number_ampersand_param() {
1475        assert_eq!(
1476            extract_page_number_from_url("https://example.com?cat=news&page=4"),
1477            Some(4)
1478        );
1479    }
1480
1481    #[test]
1482    fn test_detect_load_more_multiple_classes() {
1483        let html = r#"<html><body>
1484            <div class="show-more">Load More</div>
1485            <div class="load-more">加载更多</div>
1486        </body></html>"#;
1487        let document = Html::parse_document(html);
1488        assert!(detect_load_more(&document));
1489    }
1490
1491    #[test]
1492    fn test_extract_rel_links_with_base_resolution() {
1493        let html = r#"<html><head>
1494            <link rel="next" href="/page/2">
1495        </head></html>"#;
1496        let base = Url::parse("https://example.com/news/").ok();
1497        let document = Html::parse_document(html);
1498        let pag = extract_rel_links(&document, base.as_ref());
1499        assert_eq!(
1500            pag.next_url.as_deref(),
1501            Some("https://example.com/page/2")
1502        );
1503    }
1504
1505    #[test]
1506    fn test_extract_page_links_text_number_fallback() {
1507        let html = r#"<html><body>
1508            <div class="pagination">
1509                <a href="/news?page=abc">3</a>
1510            </div>
1511        </body></html>"#;
1512        let document = Html::parse_document(html);
1513        let links = extract_page_links(&document, None);
1514        // URL 中的 page=abc 无法解析,但链接文本 "3" 可解析为数字
1515        assert_eq!(links.len(), 1);
1516        assert_eq!(links[0].page_number, 3);
1517    }
1518
1519    // ========================================================================
1520    // 测试:PageUrl 构造
1521    // ========================================================================
1522
1523    #[test]
1524    fn test_page_url_new() {
1525        let p = PageUrl::new("https://example.com?page=5", 5, true);
1526        assert_eq!(p.url, "https://example.com?page=5");
1527        assert_eq!(p.page_number, 5);
1528        assert!(p.is_current);
1529    }
1530
1531    // ========================================================================
1532    // 测试:获取上一页/下一页带中文文本
1533    // ========================================================================
1534
1535    #[test]
1536    fn test_get_next_page_chinese_text() {
1537        let html = r#"<html><body>
1538            <div class="pagination">
1539                <a href="?page=2" class="next">下一页</a>
1540            </div>
1541        </body></html>"#;
1542        let document = Html::parse_document(html);
1543        let next = get_next_page(&document, None);
1544        assert!(next.is_some());
1545    }
1546
1547    #[test]
1548    fn test_get_prev_page_chinese_text() {
1549        let html = r#"<html><body>
1550            <div class="pagination">
1551                <a href="?page=1" class="prev">上一页</a>
1552            </div>
1553        </body></html>"#;
1554        let document = Html::parse_document(html);
1555        let prev = get_prev_page(&document, None);
1556        assert!(prev.is_some());
1557    }
1558
1559    // ========================================================================
1560    // 测试:PaginationType 序列化
1561    // ========================================================================
1562
1563    #[test]
1564    fn test_pagination_type_serialization() {
1565        assert_eq!(
1566            serde_json::to_string(&PaginationType::InfiniteScroll).unwrap(),
1567            "\"infinite_scroll\""
1568        );
1569        assert_eq!(
1570            serde_json::to_string(&PaginationType::LoadMore).unwrap(),
1571            "\"load_more\""
1572        );
1573        assert_eq!(
1574            serde_json::to_string(&PaginationType::Numbered).unwrap(),
1575            "\"numbered\""
1576        );
1577    }
1578}