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 if let Ok(n) = caps[1].parse::<u32>() {
248 return Some(n);
249 }
250 }
251
252 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 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 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
276pub fn extract_page_info_from_text(text: &str) -> (Option<u32>, Option<u32>, Option<u32>) {
290 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 let current = SIMPLE_PAGE_TEXT.captures(text)
299 .and_then(|caps| caps[1].parse::<u32>().ok());
300
301 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
308pub 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 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 if href.starts_with("http://") || href.starts_with("https://") {
329 return Url::parse(href).ok().map(|u| u.to_string());
330 }
331
332 match base_url {
334 Some(base) => base.join(href).ok().map(|u| u.to_string()),
335 None => Some(href.to_string()),
336 }
337}
338
339pub 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 if pagination.next_url.is_some() || pagination.prev_url.is_some() {
365 pagination.pagination_type = PaginationType::NextPrev;
366 }
367
368 pagination
369}
370
371pub fn extract_page_links(document: &Html, base_url: Option<&Url>) -> Vec<PageUrl> {
376 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 if !seen.insert(url_str.clone()) {
415 continue;
416 }
417
418 let is_current = element.value().attr("class")
420 .map(|c| c.contains("current") || c.contains("active"))
421 .unwrap_or(false);
422
423 let text: String = element.text().collect::<Vec<_>>().join(" ").trim().to_string();
425 let page_number =
426 extract_page_number_from_url(&url_str)
428 .or_else(|| {
430 SIMPLE_PAGE_TEXT.captures(&text)
431 .and_then(|caps| caps[1].parse::<u32>().ok())
432 })
433 .or_else(|| text.parse::<u32>().ok())
435 .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
449pub fn detect_infinite_scroll(document: &Html) -> bool {
457 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 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
487pub 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
521pub 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 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 if all_urls.iter().any(|u| OFFSET_PATTERN.is_match(u)) {
546 return PaginationType::Offset;
547 }
548
549 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 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
576pub 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 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 let page_urls = extract_page_links(document, base_url);
625 pagination.page_urls = page_urls;
626
627 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 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 pagination.has_infinite_scroll = detect_infinite_scroll(document);
648
649 pagination.has_load_more = detect_load_more(document);
651
652 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 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 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
687pub fn has_pagination(document: &Html, _html_content: &str) -> bool {
706 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 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 if detect_infinite_scroll(document) {
733 return true;
734 }
735
736 if detect_load_more(document) {
738 return true;
739 }
740
741 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
753pub fn get_next_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
768 if let Some(url) = extract_rel_link(document, "next", base_url) {
770 return Some(url);
771 }
772
773 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 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
807pub fn get_prev_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
822 if let Some(url) = extract_rel_link(document, "prev", base_url) {
824 return Some(url);
825 }
826
827 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 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
864pub fn generate_page_url(base_url: &Url, page_num: u32) -> Option<String> {
878 if PAGE_IN_QUERY.is_match(base_url.as_str()) {
880 let result = PAGE_IN_QUERY.replace(base_url.as_str(), |caps: ®ex::Captures| {
881 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 if PAGE_IN_PATH.is_match(base_url.as_str()) {
890 let result = PAGE_IN_PATH.replace(base_url.as_str(), |caps: ®ex::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 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
903fn 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#[cfg(test)]
921mod tests {
922 use super::*;
923
924 fn pagination_from_html(html: &str) -> Pagination {
926 let document = Html::parse_document(html);
927 extract_pagination(&document, None, html)
928 }
929
930 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(links.len(), 3);
1112 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); }
1135
1136 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 assert_eq!(links.len(), 1);
1531 assert_eq!(links[0].page_number, 3);
1532 }
1533
1534 #[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 #[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 #[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}