1use regex::Regex;
8use scraper::{Html, Selector};
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use url::Url;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PageUrl {
22 pub url: String,
24 pub page_number: u32,
26 pub is_current: bool,
28}
29
30impl PageUrl {
31 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum PaginationType {
45 Numbered,
47 NextPrev,
49 InfiniteScroll,
51 LoadMore,
53 Cursor,
55 Offset,
57 None,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Pagination {
66 pub current_page: u32,
68 pub total_pages: Option<u32>,
70 pub prev_url: Option<String>,
72 pub next_url: Option<String>,
74 pub first_url: Option<String>,
76 pub last_url: Option<String>,
78 pub page_urls: Vec<PageUrl>,
80 pub pagination_type: PaginationType,
82 pub has_infinite_scroll: bool,
84 pub has_load_more: bool,
86 pub items_per_page: Option<u32>,
88 pub total_items: Option<u32>,
90}
91
92impl Pagination {
93 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 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 pub fn next_page_url(&self) -> Option<&str> {
122 self.next_url.as_deref()
123 }
124
125 pub fn prev_page_url(&self) -> Option<&str> {
127 self.prev_url.as_deref()
128 }
129
130 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 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
153lazy_static::lazy_static! {
158 static ref PAGE_IN_URL: Regex = Regex::new(
160 r"(?i)(?:page|p|pg)[/=](\d+)"
161 ).expect("PAGE_IN_URL 正则编译失败");
162
163 static ref PAGE_IN_PATH: Regex = Regex::new(
165 r"(?i)/page/(\d+)/?"
166 ).expect("PAGE_IN_PATH 正则编译失败");
167
168 static ref PAGE_IN_QUERY: Regex = Regex::new(
170 r"(?i)[?&](?:page|p|pg)=(\d+)"
171 ).expect("PAGE_IN_QUERY 正则编译失败");
172
173 static ref NUMERIC_PATH: Regex = Regex::new(
175 r"/(\d+)/?$"
176 ).expect("NUMERIC_PATH 正则编译失败");
177
178 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 static ref SIMPLE_PAGE_TEXT: Regex = Regex::new(
185 r"(?i)(?:page|p[g]?)\s*(\d+)"
186 ).expect("SIMPLE_PAGE_TEXT 正则编译失败");
187
188 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 static ref PREV_TEXT: Regex = Regex::new(
195 r"(?i)^\s*(?:上一页|prev|previous|«|<|‹|←|上一页|上一頁|上一篇)\s*$"
196 ).expect("PREV_TEXT 正则编译失败");
197
198 static ref NEXT_TEXT: Regex = Regex::new(
200 r"(?i)^\s*(?:下一页|next|»|>|›|→|下一页|下一頁|下一篇)\s*$"
201 ).expect("NEXT_TEXT 正则编译失败");
202
203 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 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 static ref OFFSET_PATTERN: Regex = Regex::new(
215 r"(?i)[?&](?:offset|start|index|from)=(\d+)"
216 ).expect("OFFSET_PATTERN 正则编译失败");
217
218 static ref CURSOR_PATTERN: Regex = Regex::new(
220 r"(?i)[?&](?:cursor|after|before)=([^&]+)"
221 ).expect("CURSOR_PATTERN 正则编译失败");
222}
223
224pub fn extract_page_number_from_url(url: &str) -> Option<u32> {
245 if let Some(caps) = PAGE_IN_QUERY.captures(url)
247 && let Ok(n) = caps[1].parse::<u32>() {
248 return Some(n);
249 }
250
251 if let Some(caps) = PAGE_IN_PATH.captures(url)
253 && let Ok(n) = caps[1].parse::<u32>() {
254 return Some(n);
255 }
256
257 if let Some(caps) = PAGE_IN_URL.captures(url)
259 && let Ok(n) = caps[1].parse::<u32>() {
260 return Some(n);
261 }
262
263 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
272pub fn extract_page_info_from_text(text: &str) -> (Option<u32>, Option<u32>, Option<u32>) {
286 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 let current = SIMPLE_PAGE_TEXT.captures(text)
295 .and_then(|caps| caps[1].parse::<u32>().ok());
296
297 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
304pub 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 if href.starts_with("//") {
319 let scheme = base_url.map_or("https", url::Url::scheme);
320 return Some(format!("{scheme}:{href}"));
321 }
322
323 if href.starts_with("http://") || href.starts_with("https://") {
325 return Url::parse(href).ok().map(|u| u.to_string());
326 }
327
328 match base_url {
330 Some(base) => base.join(href).ok().map(|u| u.to_string()),
331 None => Some(href.to_string()),
332 }
333}
334
335pub 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 if pagination.next_url.is_some() || pagination.prev_url.is_some() {
361 pagination.pagination_type = PaginationType::NextPrev;
362 }
363
364 pagination
365}
366
367pub fn extract_page_links(document: &Html, base_url: Option<&Url>) -> Vec<PageUrl> {
372 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 if !seen.insert(url_str.clone()) {
411 continue;
412 }
413
414 let is_current = element.value().attr("class")
416 .is_some_and(|c| c.contains("current") || c.contains("active"));
417
418 let text: String = element.text().collect::<Vec<_>>().join(" ").trim().to_string();
420 let page_number =
421 extract_page_number_from_url(&url_str)
423 .or_else(|| {
425 SIMPLE_PAGE_TEXT.captures(&text)
426 .and_then(|caps| caps[1].parse::<u32>().ok())
427 })
428 .or_else(|| text.parse::<u32>().ok())
430 .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
444pub fn detect_infinite_scroll(document: &Html) -> bool {
452 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 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
481pub 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
515pub 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 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 if all_urls.iter().any(|u| OFFSET_PATTERN.is_match(u)) {
540 return PaginationType::Offset;
541 }
542
543 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 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
570pub 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 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 let page_urls = extract_page_links(document, base_url);
619 pagination.page_urls = page_urls;
620
621 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 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 pagination.has_infinite_scroll = detect_infinite_scroll(document);
642
643 pagination.has_load_more = detect_load_more(document);
645
646 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 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 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
678pub fn has_pagination(document: &Html, _html_content: &str) -> bool {
697 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 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 if detect_infinite_scroll(document) {
723 return true;
724 }
725
726 if detect_load_more(document) {
728 return true;
729 }
730
731 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
743pub fn get_next_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
758 if let Some(url) = extract_rel_link(document, "next", base_url) {
760 return Some(url);
761 }
762
763 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 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
795pub fn get_prev_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
810 if let Some(url) = extract_rel_link(document, "prev", base_url) {
812 return Some(url);
813 }
814
815 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 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
849pub fn generate_page_url(base_url: &Url, page_num: u32) -> Option<String> {
863 if PAGE_IN_QUERY.is_match(base_url.as_str()) {
865 let result = PAGE_IN_QUERY.replace(base_url.as_str(), |caps: ®ex::Captures| {
866 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 if PAGE_IN_PATH.is_match(base_url.as_str()) {
875 let result = PAGE_IN_PATH.replace(base_url.as_str(), |caps: ®ex::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 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
888fn 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#[cfg(test)]
906mod tests {
907 use super::*;
908
909 fn pagination_from_html(html: &str) -> Pagination {
911 let document = Html::parse_document(html);
912 extract_pagination(&document, None, html)
913 }
914
915 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(links.len(), 3);
1097 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); }
1120
1121 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 assert_eq!(links.len(), 1);
1516 assert_eq!(links[0].page_number, 3);
1517 }
1518
1519 #[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 #[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 #[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}