use regex::Regex;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use lazy_static::lazy_static;
use url::Url;
use crate::types::ParserResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Pagination {
pub current_page: Option<u32>,
pub total_pages: Option<u32>,
pub prev_url: Option<String>,
pub next_url: Option<String>,
pub first_url: Option<String>,
pub last_url: Option<String>,
pub page_urls: Vec<PageUrl>,
pub pagination_type: PaginationType,
pub has_infinite_scroll: bool,
pub has_load_more: bool,
pub items_per_page: Option<u32>,
pub total_items: Option<u32>,
}
impl Pagination {
pub fn new() -> Self {
Self::default()
}
pub fn has_pagination(&self) -> bool {
self.prev_url.is_some() ||
self.next_url.is_some() ||
!self.page_urls.is_empty() ||
self.has_infinite_scroll ||
self.has_load_more
}
pub fn has_next(&self) -> bool {
self.next_url.is_some()
}
pub fn has_prev(&self) -> bool {
self.prev_url.is_some()
}
pub fn is_first_page(&self) -> bool {
self.prev_url.is_none() && self.current_page.map(|p| p <= 1).unwrap_or(true)
}
pub fn is_last_page(&self) -> bool {
self.next_url.is_none() &&
self.current_page.is_some() &&
self.total_pages.is_some() &&
self.current_page == self.total_pages
}
pub fn all_page_urls(&self) -> Vec<String> {
let mut urls: Vec<String> = self.page_urls.iter()
.map(|p| p.url.clone())
.collect();
if let Some(ref url) = self.first_url {
if !urls.contains(url) {
urls.insert(0, url.clone());
}
}
if let Some(ref url) = self.last_url {
if !urls.contains(url) {
urls.push(url.clone());
}
}
urls
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PageUrl {
pub url: String,
pub page_number: Option<u32>,
pub is_current: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum PaginationType {
Numbered,
NextPrev,
InfiniteScroll,
LoadMore,
Cursor,
Offset,
#[default]
None,
}
lazy_static! {
static ref PAGE_QUERY_PATTERN: Regex = Regex::new(
r"(?i)[?&](page|p|pg|pn|pagenum|pagenumber|offset|start|from)=(\d+)"
).unwrap();
static ref PAGE_PATH_PATTERN: Regex = Regex::new(
r"(?i)/(?:page|p|pg)/(\d+)"
).unwrap();
static ref PAGE_END_PATTERN: Regex = Regex::new(
r"/(\d+)/?$"
).unwrap();
static ref SHOWING_PATTERN: Regex = Regex::new(
r"(?i)(?:showing|displaying)\s+(?:\d+[-–]\d+|\d+)\s+(?:of|from)\s+(\d+)"
).unwrap();
static ref PAGE_OF_PATTERN: Regex = Regex::new(
r"(?i)page\s+(\d+)\s+(?:of|/)\s+(\d+)"
).unwrap();
static ref ITEMS_PER_PAGE_PATTERN: Regex = Regex::new(
r"(?i)(\d+)\s+(?:per\s+page|results|items)"
).unwrap();
}
pub fn extract_pagination(document: &Html, base_url: Option<&Url>) -> ParserResult<Pagination> {
let mut pagination = Pagination::new();
extract_rel_links(document, &mut pagination, base_url);
extract_page_links(document, &mut pagination, base_url);
extract_page_info_from_text(document, &mut pagination);
pagination.has_infinite_scroll = detect_infinite_scroll(document);
pagination.has_load_more = detect_load_more(document);
pagination.pagination_type = determine_pagination_type(&pagination);
Ok(pagination)
}
fn extract_rel_links(document: &Html, pagination: &mut Pagination, base_url: Option<&Url>) {
if let Ok(sel) = Selector::parse("link[rel='next'], a[rel='next']") {
if let Some(el) = document.select(&sel).next() {
if let Some(href) = el.value().attr("href") {
pagination.next_url = resolve_url(href, base_url);
}
}
}
if let Ok(sel) = Selector::parse("link[rel='prev'], link[rel='previous'], a[rel='prev'], a[rel='previous']") {
if let Some(el) = document.select(&sel).next() {
if let Some(href) = el.value().attr("href") {
pagination.prev_url = resolve_url(href, base_url);
}
}
}
if let Ok(sel) = Selector::parse("link[rel='first'], a[rel='first']") {
if let Some(el) = document.select(&sel).next() {
if let Some(href) = el.value().attr("href") {
pagination.first_url = resolve_url(href, base_url);
}
}
}
if let Ok(sel) = Selector::parse("link[rel='last'], a[rel='last']") {
if let Some(el) = document.select(&sel).next() {
if let Some(href) = el.value().attr("href") {
pagination.last_url = resolve_url(href, base_url);
}
}
}
}
fn extract_page_links(document: &Html, pagination: &mut Pagination, base_url: Option<&Url>) {
let pagination_selectors = [
".pagination a",
".pager a",
".page-numbers a",
".pages a",
"nav.pagination a",
"[class*='pagination'] a",
"[class*='pager'] a",
"[aria-label='pagination'] a",
"[role='navigation'] a[href*='page']",
];
let mut seen_urls = std::collections::HashSet::new();
for selector_str in pagination_selectors {
if let Ok(sel) = Selector::parse(selector_str) {
for el in document.select(&sel) {
if let Some(href) = el.value().attr("href") {
let resolved = resolve_url(href, base_url);
if let Some(ref url) = resolved {
if seen_urls.contains(url) {
continue;
}
seen_urls.insert(url.clone());
let page_number = extract_page_number_from_url(url)
.or_else(|| {
let text = el.text().collect::<String>().trim().to_string();
text.parse::<u32>().ok()
});
let is_current = el.value().classes().any(|c|
c.contains("current") || c.contains("active") || c.contains("selected")
) || el.value().attr("aria-current").is_some();
let page_url = PageUrl {
url: url.clone(),
page_number,
is_current,
};
if is_current {
pagination.current_page = page_number;
}
pagination.page_urls.push(page_url);
}
}
}
}
}
pagination.page_urls.sort_by(|a, b| {
match (a.page_number, b.page_number) {
(Some(a), Some(b)) => a.cmp(&b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
pagination.page_urls.dedup_by(|a, b| a.url == b.url);
if let Some(max) = pagination.page_urls.iter().filter_map(|p| p.page_number).max() {
if pagination.total_pages.is_none() {
pagination.total_pages = Some(max);
}
}
}
pub fn extract_page_number_from_url(url: &str) -> Option<u32> {
if let Some(caps) = PAGE_QUERY_PATTERN.captures(url) {
if let Some(num) = caps.get(2) {
return num.as_str().parse().ok();
}
}
if let Some(caps) = PAGE_PATH_PATTERN.captures(url) {
if let Some(num) = caps.get(1) {
return num.as_str().parse().ok();
}
}
if let Some(caps) = PAGE_END_PATTERN.captures(url) {
if let Some(num) = caps.get(1) {
return num.as_str().parse().ok();
}
}
None
}
fn extract_page_info_from_text(document: &Html, pagination: &mut Pagination) {
let body_text = document.root_element().text().collect::<String>();
if let Some(caps) = PAGE_OF_PATTERN.captures(&body_text) {
if let (Some(current), Some(total)) = (caps.get(1), caps.get(2)) {
if pagination.current_page.is_none() {
pagination.current_page = current.as_str().parse().ok();
}
if pagination.total_pages.is_none() {
pagination.total_pages = total.as_str().parse().ok();
}
}
}
if let Some(caps) = SHOWING_PATTERN.captures(&body_text) {
if let Some(total) = caps.get(1) {
pagination.total_items = total.as_str().parse().ok();
}
}
if let Some(caps) = ITEMS_PER_PAGE_PATTERN.captures(&body_text) {
if let Some(per_page) = caps.get(1) {
pagination.items_per_page = per_page.as_str().parse().ok();
}
}
}
fn detect_infinite_scroll(document: &Html) -> bool {
let html = document.html().to_lowercase();
html.contains("infinite-scroll") ||
html.contains("infinitescroll") ||
html.contains("infinite_scroll") ||
html.contains("data-infinite") ||
html.contains("waypoint") ||
html.contains("scroll-trigger") ||
html.contains("lazy-load") && html.contains("scroll")
}
fn detect_load_more(document: &Html) -> bool {
let load_more_selectors = [
"button[class*='load-more']",
"button[class*='loadmore']",
"a[class*='load-more']",
"a[class*='loadmore']",
"[class*='show-more']",
"[class*='showmore']",
"[data-action='load-more']",
];
for selector_str in load_more_selectors {
if let Ok(sel) = Selector::parse(selector_str) {
if document.select(&sel).next().is_some() {
return true;
}
}
}
if let Ok(sel) = Selector::parse("button, a.btn, a.button") {
for el in document.select(&sel) {
let text = el.text().collect::<String>().to_lowercase();
if text.contains("load more") ||
text.contains("show more") ||
text.contains("view more") ||
text.contains("see more") {
return true;
}
}
}
false
}
fn determine_pagination_type(pagination: &Pagination) -> PaginationType {
if pagination.has_infinite_scroll {
return PaginationType::InfiniteScroll;
}
if pagination.has_load_more {
return PaginationType::LoadMore;
}
if !pagination.page_urls.is_empty() {
return PaginationType::Numbered;
}
if pagination.next_url.is_some() || pagination.prev_url.is_some() {
return PaginationType::NextPrev;
}
PaginationType::None
}
fn resolve_url(href: &str, base_url: Option<&Url>) -> Option<String> {
if href.starts_with("http://") || href.starts_with("https://") {
return Some(href.to_string());
}
if href.starts_with("//") {
return Some(format!("https:{}", href));
}
if let Some(base) = base_url {
return base.join(href).ok().map(|u| u.to_string());
}
None
}
pub fn has_pagination(document: &Html) -> bool {
extract_pagination(document, None)
.map(|p| p.has_pagination())
.unwrap_or(false)
}
pub fn get_next_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
extract_pagination(document, base_url)
.ok()
.and_then(|p| p.next_url)
}
pub fn get_prev_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
extract_pagination(document, base_url)
.ok()
.and_then(|p| p.prev_url)
}
pub fn generate_page_url(base_url: &str, page_number: u32, pattern: &str) -> String {
pattern.replace("{page}", &page_number.to_string())
.replace("{url}", base_url)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_html(html: &str) -> Html {
Html::parse_document(html)
}
#[test]
fn test_extract_rel_next_prev() {
let html = r#"
<html>
<head>
<link rel="prev" href="/page/1">
<link rel="next" href="/page/3">
</head>
<body></body>
</html>
"#;
let doc = parse_html(html);
let base = Url::parse("https://example.com/page/2").unwrap();
let pagination = extract_pagination(&doc, Some(&base)).unwrap();
assert_eq!(pagination.prev_url, Some("https://example.com/page/1".to_string()));
assert_eq!(pagination.next_url, Some("https://example.com/page/3".to_string()));
assert!(pagination.has_pagination());
}
#[test]
fn test_extract_numbered_pagination() {
let html = r#"
<div class="pagination">
<a href="/page/1">1</a>
<a href="/page/2" class="active">2</a>
<a href="/page/3">3</a>
<a href="/page/4">4</a>
</div>
"#;
let doc = parse_html(html);
let base = Url::parse("https://example.com/page/2").unwrap();
let pagination = extract_pagination(&doc, Some(&base)).unwrap();
assert_eq!(pagination.page_urls.len(), 4);
assert_eq!(pagination.current_page, Some(2));
assert_eq!(pagination.total_pages, Some(4));
assert_eq!(pagination.pagination_type, PaginationType::Numbered);
}
#[test]
fn test_detect_infinite_scroll() {
let html = r#"
<html>
<body>
<div class="infinite-scroll" data-infinite="true">
Content here
</div>
</body>
</html>
"#;
let doc = parse_html(html);
let pagination = extract_pagination(&doc, None).unwrap();
assert!(pagination.has_infinite_scroll);
assert_eq!(pagination.pagination_type, PaginationType::InfiniteScroll);
}
#[test]
fn test_detect_load_more() {
let html = r#"
<html>
<body>
<div class="items">Items...</div>
<button class="load-more">Load More</button>
</body>
</html>
"#;
let doc = parse_html(html);
let pagination = extract_pagination(&doc, None).unwrap();
assert!(pagination.has_load_more);
}
#[test]
fn test_extract_page_number_from_url() {
assert_eq!(extract_page_number_from_url("/articles?page=5"), Some(5));
assert_eq!(extract_page_number_from_url("/blog/page/3"), Some(3));
assert_eq!(extract_page_number_from_url("/posts?p=10"), Some(10));
assert_eq!(extract_page_number_from_url("/items?offset=20"), Some(20));
assert_eq!(extract_page_number_from_url("/no-page-here"), None);
}
#[test]
fn test_page_of_text_detection() {
let html = r#"
<html>
<body>
<p>Page 3 of 10</p>
<p>Showing 21-30 of 100 results</p>
</body>
</html>
"#;
let doc = parse_html(html);
let pagination = extract_pagination(&doc, None).unwrap();
assert_eq!(pagination.current_page, Some(3));
assert_eq!(pagination.total_pages, Some(10));
assert_eq!(pagination.total_items, Some(100));
}
#[test]
fn test_is_first_last_page() {
let mut pagination = Pagination::new();
pagination.current_page = Some(1);
pagination.total_pages = Some(5);
assert!(pagination.is_first_page());
assert!(!pagination.is_last_page());
pagination.current_page = Some(5);
assert!(!pagination.is_first_page());
assert!(pagination.is_last_page());
}
#[test]
fn test_all_page_urls() {
let mut pagination = Pagination::new();
pagination.first_url = Some("/page/1".to_string());
pagination.last_url = Some("/page/5".to_string());
pagination.page_urls = vec![
PageUrl { url: "/page/2".to_string(), page_number: Some(2), is_current: false },
PageUrl { url: "/page/3".to_string(), page_number: Some(3), is_current: true },
];
let all_urls = pagination.all_page_urls();
assert_eq!(all_urls.len(), 4);
assert_eq!(all_urls[0], "/page/1");
assert_eq!(all_urls[3], "/page/5");
}
#[test]
fn test_generate_page_url() {
let url = generate_page_url("https://example.com", 5, "{url}/page/{page}");
assert_eq!(url, "https://example.com/page/5");
}
#[test]
fn test_no_pagination() {
let html = "<html><body><p>Just content, no pagination</p></body></html>";
let doc = parse_html(html);
let pagination = extract_pagination(&doc, None).unwrap();
assert!(!pagination.has_pagination());
assert_eq!(pagination.pagination_type, PaginationType::None);
}
#[test]
fn test_load_more_text_button() {
let html = r#"
<html>
<body>
<button class="btn">Load more items</button>
</body>
</html>
"#;
let doc = parse_html(html);
let pagination = extract_pagination(&doc, None).unwrap();
assert!(pagination.has_load_more);
}
#[test]
fn test_aria_pagination() {
let html = r#"
<nav aria-label="pagination">
<a href="/page/1">1</a>
<a href="/page/2" aria-current="page">2</a>
<a href="/page/3">3</a>
</nav>
"#;
let doc = parse_html(html);
let base = Url::parse("https://example.com/").unwrap();
let pagination = extract_pagination(&doc, Some(&base)).unwrap();
assert!(!pagination.page_urls.is_empty());
}
}