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