use regex::Regex;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageUrl {
pub url: String,
pub page_number: u32,
pub is_current: bool,
}
impl PageUrl {
pub fn new(url: impl Into<String>, page_number: u32, is_current: bool) -> Self {
Self {
url: url.into(),
page_number,
is_current,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaginationType {
Numbered,
NextPrev,
InfiniteScroll,
LoadMore,
Cursor,
Offset,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pagination {
pub current_page: 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 none() -> Self {
Self {
current_page: 1,
total_pages: None,
prev_url: None,
next_url: None,
first_url: None,
last_url: None,
page_urls: Vec::new(),
pagination_type: PaginationType::None,
has_infinite_scroll: false,
has_load_more: false,
items_per_page: None,
total_items: None,
}
}
pub fn has_pagination(&self) -> bool {
self.pagination_type != PaginationType::None
|| self.page_urls.len() > 1
|| self.next_url.is_some()
|| self.has_infinite_scroll
|| self.has_load_more
}
pub fn next_page_url(&self) -> Option<&str> {
self.next_url.as_deref()
}
pub fn prev_page_url(&self) -> Option<&str> {
self.prev_url.as_deref()
}
pub fn numbered_pages(&self) -> Vec<&PageUrl> {
self.page_urls
.iter()
.filter(|p| !self.is_nav_link(&p.url))
.collect()
}
fn is_nav_link(&self, url: &str) -> bool {
Some(url) == self.prev_url.as_deref()
|| Some(url) == self.next_url.as_deref()
|| Some(url) == self.first_url.as_deref()
|| Some(url) == self.last_url.as_deref()
}
}
impl Default for Pagination {
fn default() -> Self {
Self::none()
}
}
lazy_static::lazy_static! {
static ref PAGE_IN_URL: Regex = Regex::new(
r"(?i)(?:page|p|pg)[/=](\d+)"
).expect("PAGE_IN_URL 正则编译失败");
static ref PAGE_IN_PATH: Regex = Regex::new(
r"(?i)/page/(\d+)/?"
).expect("PAGE_IN_PATH 正则编译失败");
static ref PAGE_IN_QUERY: Regex = Regex::new(
r"(?i)[?&](?:page|p|pg)=(\d+)"
).expect("PAGE_IN_QUERY 正则编译失败");
static ref NUMERIC_PATH: Regex = Regex::new(
r"/(\d+)/?$"
).expect("NUMERIC_PATH 正则编译失败");
static ref PAGE_TEXT: Regex = Regex::new(
r"(?i)(?:page|p[g]?)\s*(\d+)\s*(?:of|/)\s*(\d+)"
).expect("PAGE_TEXT 正则编译失败");
static ref SIMPLE_PAGE_TEXT: Regex = Regex::new(
r"(?i)(?:page|p[g]?)\s*(\d+)"
).expect("SIMPLE_PAGE_TEXT 正则编译失败");
static ref TOTAL_ITEMS_TEXT: Regex = Regex::new(
r"(?i)(?:共|total|of)\s*(\d+)\s*(?:条|个|items?|results?|页)?[条个]?[\u4e00-\u9fff]*\s*$"
).expect("TOTAL_ITEMS_TEXT 正则编译失败");
static ref PREV_TEXT: Regex = Regex::new(
r"(?i)^\s*(?:上一页|prev|previous|«|<|‹|←|上一页|上一頁|上一篇)\s*$"
).expect("PREV_TEXT 正则编译失败");
static ref NEXT_TEXT: Regex = Regex::new(
r"(?i)^\s*(?:下一页|next|»|>|›|→|下一页|下一頁|下一篇)\s*$"
).expect("NEXT_TEXT 正则编译失败");
static ref LOAD_MORE_TEXT: Regex = Regex::new(
r"(?i)(?:load\s*more|show\s*more|view\s*more|加载更多|查看更多|展开更多)"
).expect("LOAD_MORE_TEXT 正则编译失败");
static ref INFINITE_SCROLL_PATTERN: Regex = Regex::new(
r"(?i)(?:infinite[\s-]?scroll|infinite[\s-]?load|endless[\s-]?(?:scroll|page))"
).expect("INFINITE_SCROLL_PATTERN 正则编译失败");
static ref OFFSET_PATTERN: Regex = Regex::new(
r"(?i)[?&](?:offset|start|index|from)=(\d+)"
).expect("OFFSET_PATTERN 正则编译失败");
static ref CURSOR_PATTERN: Regex = Regex::new(
r"(?i)[?&](?:cursor|after|before)=([^&]+)"
).expect("CURSOR_PATTERN 正则编译失败");
}
pub fn extract_page_number_from_url(url: &str) -> Option<u32> {
if let Some(caps) = PAGE_IN_QUERY.captures(url) {
if let Ok(n) = caps[1].parse::<u32>() {
return Some(n);
}
}
if let Some(caps) = PAGE_IN_PATH.captures(url) {
if let Ok(n) = caps[1].parse::<u32>() {
return Some(n);
}
}
if let Some(caps) = PAGE_IN_URL.captures(url) {
if let Ok(n) = caps[1].parse::<u32>() {
return Some(n);
}
}
if let Some(caps) = NUMERIC_PATH.captures(url) {
if let Ok(n) = caps[1].parse::<u32>() {
return Some(n);
}
}
None
}
pub fn extract_page_info_from_text(text: &str) -> (Option<u32>, Option<u32>, Option<u32>) {
if let Some(caps) = PAGE_TEXT.captures(text) {
let current = caps[1].parse::<u32>().ok();
let total = caps[2].parse::<u32>().ok();
return (current, total, None);
}
let current = SIMPLE_PAGE_TEXT.captures(text)
.and_then(|caps| caps[1].parse::<u32>().ok());
let total_items = TOTAL_ITEMS_TEXT.captures(text)
.and_then(|caps| caps[1].parse::<u32>().ok());
(current, None, total_items)
}
pub fn resolve_url(href: &str, base_url: Option<&Url>) -> Option<String> {
let href = href.trim();
if href.is_empty() {
return None;
}
if href.starts_with("//") {
let scheme = base_url.map(|u| u.scheme()).unwrap_or("https");
return Some(format!("{scheme}:{href}"));
}
if href.starts_with("http://") || href.starts_with("https://") {
return Url::parse(href).ok().map(|u| u.to_string());
}
match base_url {
Some(base) => base.join(href).ok().map(|u| u.to_string()),
None => Some(href.to_string()),
}
}
pub fn extract_rel_links(document: &Html, base_url: Option<&Url>) -> Pagination {
let selector = Selector::parse("link[rel][href]").expect("选择器 link[rel][href] 应合法");
let mut pagination = Pagination::none();
for element in document.select(&selector) {
let rel = element.value().attr("rel").unwrap_or("").to_lowercase();
let href = match element.value().attr("href") {
Some(h) => resolve_url(h, base_url).unwrap_or_else(|| h.to_string()),
None => continue,
};
match rel.as_str() {
"next" => pagination.next_url = Some(href),
"prev" | "previous" => pagination.prev_url = Some(href),
"first" => pagination.first_url = Some(href),
"last" => pagination.last_url = Some(href),
_ => {}
}
}
if pagination.next_url.is_some() || pagination.prev_url.is_some() {
pagination.pagination_type = PaginationType::NextPrev;
}
pagination
}
pub fn extract_page_links(document: &Html, base_url: Option<&Url>) -> Vec<PageUrl> {
let selectors = [
".pagination a",
".pager a",
".page-nav a",
".page-navigation a",
".pages a",
".page-numbers",
"nav.pagination a",
"ul.pagination a",
"div.pagination a",
"[class*=\"pagination\"] a",
"[class*=\"pager\"] a",
"[class*=\"page-nav\"] a",
];
let mut page_urls = Vec::new();
let mut seen = HashSet::new();
for selector_str in &selectors {
let Ok(selector) = Selector::parse(selector_str) else {
continue;
};
for element in document.select(&selector) {
let href = match element.value().attr("href") {
Some(h) => h.trim(),
None => continue,
};
if href.is_empty() || href.starts_with('#') || href.starts_with("javascript:") {
continue;
}
let resolved = resolve_url(href, base_url);
let url_str = resolved.as_deref().unwrap_or(href).to_string();
if !seen.insert(url_str.clone()) {
continue;
}
let is_current = element.value().attr("class")
.map(|c| c.contains("current") || c.contains("active"))
.unwrap_or(false);
let text: String = element.text().collect::<Vec<_>>().join(" ").trim().to_string();
let page_number =
extract_page_number_from_url(&url_str)
.or_else(|| {
SIMPLE_PAGE_TEXT.captures(&text)
.and_then(|caps| caps[1].parse::<u32>().ok())
})
.or_else(|| text.parse::<u32>().ok())
.unwrap_or(0);
page_urls.push(PageUrl {
url: url_str,
page_number,
is_current,
});
}
}
page_urls
}
pub fn detect_infinite_scroll(document: &Html) -> bool {
let attr_selectors = [
"[class*=\"infinite-scroll\"]",
"[class*=\"infinite-scroll-container\"]",
"[id*=\"infinite-scroll\"]",
"[data-infinite-scroll]",
"[data-infinite]",
"[class*=\"endless-scroll\"]",
];
for selector_str in &attr_selectors {
if let Ok(selector) = Selector::parse(selector_str) {
if document.select(&selector).next().is_some() {
return true;
}
}
}
let script_selector = Selector::parse("script").expect("script 选择器应合法");
for element in document.select(&script_selector) {
let content: String = element.text().collect();
if INFINITE_SCROLL_PATTERN.is_match(&content) {
return true;
}
}
false
}
pub fn detect_load_more(document: &Html) -> bool {
let selectors = [
".load-more",
".loadmore",
".show-more",
".view-more",
"[class*=\"load-more\"]",
"[class*=\"loadmore\"]",
"[class*=\"show-more\"]",
"[data-load-more]",
"[data-loadmore]",
"button.load-more",
"a.load-more",
"button.show-more",
"a.show-more",
];
for selector_str in &selectors {
if let Ok(selector) = Selector::parse(selector_str) {
for element in document.select(&selector) {
let text: String = element.text().collect();
if LOAD_MORE_TEXT.is_match(&text) {
return true;
}
}
}
}
false
}
pub fn determine_pagination_type(
page_urls: &[PageUrl],
has_infinite_scroll: bool,
has_load_more: bool,
document: &Html,
) -> PaginationType {
if has_infinite_scroll {
return PaginationType::InfiniteScroll;
}
if has_load_more {
return PaginationType::LoadMore;
}
let all_urls: Vec<&str> = page_urls.iter().map(|p| p.url.as_str()).collect();
if all_urls.iter().any(|u| CURSOR_PATTERN.is_match(u)) {
return PaginationType::Cursor;
}
if all_urls.iter().any(|u| OFFSET_PATTERN.is_match(u)) {
return PaginationType::Offset;
}
let numbered_count = page_urls.iter().filter(|p| p.page_number > 0).count();
if numbered_count >= 2 {
return PaginationType::Numbered;
}
let text_selector = Selector::parse("a, span, button").expect("选择器 a, span, button 应合法");
let mut has_prev = false;
let mut has_next = false;
for element in document.select(&text_selector) {
let text: String = element.text().collect();
if PREV_TEXT.is_match(&text) {
has_prev = true;
}
if NEXT_TEXT.is_match(&text) {
has_next = true;
}
if has_prev && has_next {
return PaginationType::NextPrev;
}
}
PaginationType::None
}
pub fn extract_pagination(
document: &Html,
base_url: Option<&Url>,
_html_content: &str,
) -> Pagination {
let mut pagination = Pagination::none();
let rel_pagination = extract_rel_links(document, base_url);
pagination.next_url = rel_pagination.next_url;
pagination.prev_url = rel_pagination.prev_url;
pagination.first_url = rel_pagination.first_url;
pagination.last_url = rel_pagination.last_url;
let page_urls = extract_page_links(document, base_url);
pagination.page_urls = page_urls;
let current_from_urls = pagination.page_urls.iter()
.find(|p| p.is_current)
.map(|p| p.page_number);
let current_from_next = pagination.next_url.as_deref()
.and_then(extract_page_number_from_url)
.map(|n| n.saturating_sub(1));
let current_from_prev = pagination.prev_url.as_deref()
.and_then(extract_page_number_from_url)
.map(|n| n.saturating_add(1));
pagination.current_page = current_from_urls
.or(current_from_prev)
.or(current_from_next)
.unwrap_or(1);
pagination.has_infinite_scroll = detect_infinite_scroll(document);
pagination.has_load_more = detect_load_more(document);
pagination.pagination_type = determine_pagination_type(
&pagination.page_urls,
pagination.has_infinite_scroll,
pagination.has_load_more,
document,
);
let text_selector = Selector::parse("body").expect("body 选择器应合法");
if let Some(body) = document.select(&text_selector).next() {
let body_text: String = body.text().collect();
let (cur, total, items) = extract_page_info_from_text(&body_text);
if pagination.current_page == 1 && cur.is_some() {
pagination.current_page = cur.unwrap_or(1);
}
pagination.total_pages = pagination.total_pages.or(total);
pagination.total_items = pagination.total_items.or(items);
}
if let Some(ref base) = base_url {
if let Some(total_str) = base.query_pairs()
.find(|(k, _)| k == "total" || k == "pages")
.map(|(_, v)| v.to_string())
{
if let Ok(total) = total_str.parse::<u32>() {
pagination.total_pages = Some(total);
}
}
}
pagination
}
pub fn has_pagination(document: &Html, _html_content: &str) -> bool {
let rel_selector = Selector::parse("link[rel=\"next\"], link[rel=\"prev\"]")
.expect("选择器应合法");
if document.select(&rel_selector).next().is_some() {
return true;
}
let pagination_classes = [
".pagination",
".pager",
".page-nav",
".page-numbers",
"[class*=\"pagination\"]",
];
let any_pagination = pagination_classes.iter().any(|cls| {
Selector::parse(cls)
.ok()
.map(|sel| document.select(&sel).next().is_some())
.unwrap_or(false)
});
if any_pagination {
return true;
}
if detect_infinite_scroll(document) {
return true;
}
if detect_load_more(document) {
return true;
}
let body_selector = Selector::parse("body").expect("body 选择器应合法");
if let Some(body) = document.select(&body_selector).next() {
let text: String = body.text().collect();
if PREV_TEXT.is_match(&text) || NEXT_TEXT.is_match(&text) || PAGE_TEXT.is_match(&text) {
return true;
}
}
false
}
pub fn get_next_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
if let Some(url) = extract_rel_link(document, "next", base_url) {
return Some(url);
}
let selectors = [
"a.next",
"a.next-page",
"a[rel=\"next\"]",
".pagination a:last-child",
".pager a:last-child",
];
for selector_str in &selectors {
let Ok(selector) = Selector::parse(selector_str) else {
continue;
};
for element in document.select(&selector) {
let text: String = element.text().collect();
if NEXT_TEXT.is_match(&text) {
if let Some(href) = element.value().attr("href") {
return resolve_url(href, base_url);
}
}
}
}
let page_urls = extract_page_links(document, base_url);
let current = page_urls.iter().find(|p| p.is_current).map(|p| p.page_number);
if let Some(cur) = current {
if let Some(next) = page_urls.iter().find(|p| p.page_number == cur + 1) {
return Some(next.url.clone());
}
}
None
}
pub fn get_prev_page(document: &Html, base_url: Option<&Url>) -> Option<String> {
if let Some(url) = extract_rel_link(document, "prev", base_url) {
return Some(url);
}
let selectors = [
"a.prev",
"a.previous",
"a.prev-page",
"a[rel=\"prev\"]",
".pagination a:first-child",
".pager a:first-child",
];
for selector_str in &selectors {
let Ok(selector) = Selector::parse(selector_str) else {
continue;
};
for element in document.select(&selector) {
let text: String = element.text().collect();
if PREV_TEXT.is_match(&text) {
if let Some(href) = element.value().attr("href") {
return resolve_url(href, base_url);
}
}
}
}
let page_urls = extract_page_links(document, base_url);
let current = page_urls.iter().find(|p| p.is_current).map(|p| p.page_number);
if let Some(cur) = current {
if cur > 1 {
if let Some(prev) = page_urls.iter().find(|p| p.page_number == cur - 1) {
return Some(prev.url.clone());
}
}
}
None
}
pub fn generate_page_url(base_url: &Url, page_num: u32) -> Option<String> {
if PAGE_IN_QUERY.is_match(base_url.as_str()) {
let result = PAGE_IN_QUERY.replace(base_url.as_str(), |caps: ®ex::Captures| {
let prefix = &caps[0][..caps[0].len() - caps[1].len()];
format!("{}{}", prefix, page_num)
});
return Some(result.to_string());
}
if PAGE_IN_PATH.is_match(base_url.as_str()) {
let result = PAGE_IN_PATH.replace(base_url.as_str(), |caps: ®ex::Captures| {
let prefix = &caps[0][..caps[0].len() - caps[1].len()];
format!("{}{}", prefix, page_num)
});
return Some(result.to_string());
}
let mut url = base_url.clone();
url.query_pairs_mut().append_pair("page", &page_num.to_string());
Some(url.to_string())
}
fn extract_rel_link(document: &Html, rel_value: &str, base_url: Option<&Url>) -> Option<String> {
let selector_str = format!("link[rel=\"{}\"][href]", rel_value);
let selector = Selector::parse(&selector_str).ok()?;
let element = document.select(&selector).next()?;
let href = element.value().attr("href")?;
resolve_url(href, base_url)
}
#[cfg(test)]
mod tests {
use super::*;
fn pagination_from_html(html: &str) -> Pagination {
let document = Html::parse_document(html);
extract_pagination(&document, None, html)
}
#[test]
fn test_no_pagination() {
let html = "<html><body><p>Hello, world!</p></body></html>";
let pag = pagination_from_html(html);
assert_eq!(pag.pagination_type, PaginationType::None);
assert!(!pag.has_pagination());
assert!(pag.page_urls.is_empty());
assert!(pag.next_url.is_none());
assert!(pag.prev_url.is_none());
assert_eq!(pag.current_page, 1);
}
#[test]
fn test_extract_page_number_from_query() {
assert_eq!(extract_page_number_from_url("https://example.com?page=3"), Some(3));
assert_eq!(extract_page_number_from_url("https://example.com?p=5"), Some(5));
assert_eq!(extract_page_number_from_url("https://example.com?pg=2"), Some(2));
}
#[test]
fn test_extract_page_number_from_path() {
assert_eq!(extract_page_number_from_url("https://example.com/page/3"), Some(3));
assert_eq!(extract_page_number_from_url("https://example.com/page/10/"), Some(10));
}
#[test]
fn test_extract_page_number_from_numeric_suffix() {
assert_eq!(extract_page_number_from_url("https://example.com/articles/42/"), Some(42));
}
#[test]
fn test_extract_page_number_no_match() {
assert_eq!(extract_page_number_from_url("https://example.com/about"), None);
assert_eq!(extract_page_number_from_url("https://example.com"), None);
}
#[test]
fn test_extract_page_number_case_insensitive() {
assert_eq!(extract_page_number_from_url("https://example.com?Page=7"), Some(7));
assert_eq!(extract_page_number_from_url("https://example.com/PAGE/2"), Some(2));
}
#[test]
fn test_extract_page_info_full() {
let (cur, total, items) = extract_page_info_from_text("Page 3 of 10");
assert_eq!(cur, Some(3));
assert_eq!(total, Some(10));
assert_eq!(items, None);
}
#[test]
fn test_extract_page_info_simple() {
let (cur, total, items) = extract_page_info_from_text("Page 5");
assert_eq!(cur, Some(5));
assert_eq!(total, None);
assert_eq!(items, None);
}
#[test]
fn test_extract_page_info_total_items() {
let (cur, total, items) = extract_page_info_from_text("共 200 条结果");
assert_eq!(cur, None);
assert_eq!(total, None);
assert_eq!(items, Some(200));
}
#[test]
fn test_extract_page_info_no_match() {
let (cur, total, items) = extract_page_info_from_text("Hello World");
assert_eq!(cur, None);
assert_eq!(total, None);
assert_eq!(items, None);
}
#[test]
fn test_resolve_url_absolute() {
let base = Url::parse("https://example.com").ok();
let result = resolve_url("https://other.com/path", base.as_ref());
assert_eq!(result.as_deref(), Some("https://other.com/path"));
}
#[test]
fn test_resolve_url_relative() {
let base = Url::parse("https://example.com/base/").ok();
let result = resolve_url("../page", base.as_ref());
assert_eq!(result.as_deref(), Some("https://example.com/page"));
}
#[test]
fn test_resolve_url_protocol_relative() {
let base = Url::parse("https://example.com").ok();
let result = resolve_url("//other.com/path", base.as_ref());
assert_eq!(result.as_deref(), Some("https://other.com/path"));
}
#[test]
fn test_resolve_url_empty() {
assert!(resolve_url("", None).is_none());
assert!(resolve_url(" ", None).is_none());
}
#[test]
fn test_extract_rel_links_next_prev() {
let html = r#"<html><head>
<link rel="next" href="https://example.com?page=2">
<link rel="prev" href="https://example.com?page=1">
</head></html>"#;
let document = Html::parse_document(html);
let pag = extract_rel_links(&document, None);
assert_eq!(pag.next_url.as_deref(), Some("https://example.com/?page=2"));
assert_eq!(pag.prev_url.as_deref(), Some("https://example.com/?page=1"));
assert_eq!(pag.pagination_type, PaginationType::NextPrev);
}
#[test]
fn test_extract_rel_links_first_last() {
let html = r#"<html><head>
<link rel="first" href="https://example.com">
<link rel="last" href="https://example.com?page=50">
</head></html>"#;
let document = Html::parse_document(html);
let pag = extract_rel_links(&document, None);
assert_eq!(pag.first_url.as_deref(), Some("https://example.com/"));
assert_eq!(pag.last_url.as_deref(), Some("https://example.com/?page=50"));
}
#[test]
fn test_extract_page_links_numbered() {
let html = r#"<html><body>
<div class="pagination">
<a href="?page=1" class="current">1</a>
<a href="?page=2">2</a>
<a href="?page=3">3</a>
</div>
</body></html>"#;
let document = Html::parse_document(html);
let links = extract_page_links(&document, None);
assert_eq!(links.len(), 3);
assert!(links[0].is_current);
assert_eq!(links[0].page_number, 1);
assert!(!links[1].is_current);
assert_eq!(links[1].page_number, 2);
assert_eq!(links[2].page_number, 3);
}
#[test]
fn test_extract_page_links_with_text_numbers() {
let html = r#"<html><body>
<ul class="pagination">
<li><a href="/page/1">1</a></li>
<li><a href="/page/2" class="active">2</a></li>
<li><a href="/page/3">3</a></li>
</ul>
</body></html>"#;
let document = Html::parse_document(html);
let links = extract_page_links(&document, None);
assert_eq!(links.len(), 3);
assert_eq!(links[0].page_number, 1);
assert!(!links[0].is_current);
assert_eq!(links[1].page_number, 2);
assert!(links[1].is_current);
}
#[test]
fn test_extract_page_links_deduplicates() {
let html = r#"<html><body>
<div class="pagination">
<a href="?page=1">1</a>
<a href="?page=2">2</a>
</div>
<nav class="pagination">
<a href="?page=1">1</a>
<a href="?page=2">2</a>
</nav>
</body></html>"#;
let document = Html::parse_document(html);
let links = extract_page_links(&document, None);
assert_eq!(links.len(), 2); }
#[test]
fn test_detect_infinite_scroll_by_class() {
let html = r#"<html><body><div class="infinite-scroll"></div></body></html>"#;
let document = Html::parse_document(html);
assert!(detect_infinite_scroll(&document));
}
#[test]
fn test_detect_infinite_scroll_by_data_attr() {
let html = r#"<html><body><div data-infinite-scroll="true"></div></body></html>"#;
let document = Html::parse_document(html);
assert!(detect_infinite_scroll(&document));
}
#[test]
fn test_detect_infinite_scroll_by_script() {
let html = r#"<html><body><script>var infiniteScroll = true;</script></body></html>"#;
let document = Html::parse_document(html);
assert!(detect_infinite_scroll(&document));
}
#[test]
fn test_detect_infinite_scroll_none() {
let html = r#"<html><body><p>No scroll here</p></body></html>"#;
let document = Html::parse_document(html);
assert!(!detect_infinite_scroll(&document));
}
#[test]
fn test_detect_load_more_by_class() {
let html = r#"<html><body><button class="load-more">加载更多</button></body></html>"#;
let document = Html::parse_document(html);
assert!(detect_load_more(&document));
}
#[test]
fn test_detect_load_more_by_text() {
let html = r#"<html><body><a class="show-more">查看更多</a></body></html>"#;
let document = Html::parse_document(html);
assert!(detect_load_more(&document));
}
#[test]
fn test_detect_load_more_english() {
let html = r#"<html><body><button class="load-more">Load More</button></body></html>"#;
let document = Html::parse_document(html);
assert!(detect_load_more(&document));
}
#[test]
fn test_detect_load_more_none() {
let html = r#"<html><body><button>提交</button></body></html>"#;
let document = Html::parse_document(html);
assert!(!detect_load_more(&document));
}
#[test]
fn test_determine_pagination_type_numbered() {
let urls = vec![
PageUrl::new("?page=1", 1, true),
PageUrl::new("?page=2", 2, false),
PageUrl::new("?page=3", 3, false),
];
let document = Html::parse_document("<html></html>");
let ptype = determine_pagination_type(&urls, false, false, &document);
assert_eq!(ptype, PaginationType::Numbered);
}
#[test]
fn test_determine_pagination_type_infinite_scroll() {
let urls = vec![];
let document = Html::parse_document("<html></html>");
let ptype = determine_pagination_type(&urls, true, false, &document);
assert_eq!(ptype, PaginationType::InfiniteScroll);
}
#[test]
fn test_determine_pagination_type_load_more() {
let urls = vec![];
let document = Html::parse_document("<html></html>");
let ptype = determine_pagination_type(&urls, false, true, &document);
assert_eq!(ptype, PaginationType::LoadMore);
}
#[test]
fn test_determine_pagination_type_next_prev_from_text() {
let urls = vec![];
let html = r#"<html><body><a>上一页</a><a>下一页</a></body></html>"#;
let document = Html::parse_document(html);
let ptype = determine_pagination_type(&urls, false, false, &document);
assert_eq!(ptype, PaginationType::NextPrev);
}
#[test]
fn test_determine_pagination_type_cursor() {
let urls = vec![
PageUrl::new("https://example.com?cursor=abc", 0, false),
];
let document = Html::parse_document("<html></html>");
let ptype = determine_pagination_type(&urls, false, false, &document);
assert_eq!(ptype, PaginationType::Cursor);
}
#[test]
fn test_determine_pagination_type_offset() {
let urls = vec![
PageUrl::new("https://example.com?offset=10", 0, false),
];
let document = Html::parse_document("<html></html>");
let ptype = determine_pagination_type(&urls, false, false, &document);
assert_eq!(ptype, PaginationType::Offset);
}
#[test]
fn test_extract_pagination_numbered() {
let html = r#"<html><head>
<link rel="next" href="?page=2">
</head><body>
<div class="pagination">
<a href="?page=1" class="current">1</a>
<a href="?page=2">2</a>
<a href="?page=3">3</a>
</div>
</body></html>"#;
let pag = pagination_from_html(html);
assert_eq!(pag.pagination_type, PaginationType::Numbered);
assert!(pag.has_pagination());
assert_eq!(pag.page_urls.len(), 3);
assert_eq!(pag.current_page, 1);
assert_eq!(pag.next_url.as_deref(), Some("?page=2"));
}
#[test]
fn test_extract_pagination_with_base_url() {
let html = r#"<html><body>
<div class="pagination">
<a href="/page/2">2</a>
<a href="/page/3">3</a>
</div>
</body></html>"#;
let base = Url::parse("https://example.com/page/1").ok();
let document = Html::parse_document(html);
let pag = extract_pagination(&document, base.as_ref(), html);
assert!(pag.has_pagination());
assert_eq!(pag.page_urls.len(), 2);
assert_eq!(pag.page_urls[0].url, "https://example.com/page/2");
assert_eq!(pag.page_urls[1].url, "https://example.com/page/3");
}
#[test]
fn test_extract_pagination_infinite_scroll() {
let html = r#"<html><body>
<div class="infinite-scroll" data-infinite-scroll="true"></div>
<script>var infiniteScroll = true;</script>
</body></html>"#;
let pag = pagination_from_html(html);
assert_eq!(pag.pagination_type, PaginationType::InfiniteScroll);
assert!(pag.has_infinite_scroll);
assert!(pag.has_pagination());
}
#[test]
fn test_extract_pagination_load_more() {
let html = r#"<html><body>
<button class="load-more">加载更多</button>
</body></html>"#;
let pag = pagination_from_html(html);
assert_eq!(pag.pagination_type, PaginationType::LoadMore);
assert!(pag.has_load_more);
assert!(pag.has_pagination());
}
#[test]
fn test_has_pagination_pagination_class() {
let html = r#"<html><body><div class="pagination"></div></body></html>"#;
let document = Html::parse_document(html);
assert!(has_pagination(&document, html));
}
#[test]
fn test_has_pagination_rel_links() {
let html = r#"<html><head><link rel="next" href="?page=2"></head></html>"#;
let document = Html::parse_document(html);
assert!(has_pagination(&document, html));
}
#[test]
fn test_has_pagination_no_pagination() {
let html = "<html><body><p>No pagination</p></body></html>";
let document = Html::parse_document(html);
assert!(!has_pagination(&document, html));
}
#[test]
fn test_get_next_page_from_rel() {
let html = r#"<html><head><link rel="next" href="https://example.com?page=2"></head></html>"#;
let document = Html::parse_document(html);
assert_eq!(
get_next_page(&document, None),
Some("https://example.com/?page=2".to_string())
);
}
#[test]
fn test_get_next_page_from_dom() {
let html = r#"<html><body>
<div class="pagination">
<a href="?page=1" class="current">1</a>
<a href="?page=2">2</a>
<a href="?page=3">3</a>
</div>
</body></html>"#;
let document = Html::parse_document(html);
let next = get_next_page(&document, None);
assert!(next.is_some());
}
#[test]
fn test_get_next_page_not_found() {
let html = "<html><body><p>No pagination</p></body></html>";
let document = Html::parse_document(html);
assert!(get_next_page(&document, None).is_none());
}
#[test]
fn test_get_prev_page_from_rel() {
let html = r#"<html><head><link rel="prev" href="https://example.com?page=1"></head></html>"#;
let document = Html::parse_document(html);
assert_eq!(
get_prev_page(&document, None),
Some("https://example.com/?page=1".to_string())
);
}
#[test]
fn test_generate_page_url_replace_query() {
let base = Url::parse("https://example.com?page=1").unwrap();
assert_eq!(
generate_page_url(&base, 3),
Some("https://example.com/?page=3".to_string())
);
}
#[test]
fn test_generate_page_url_replace_path() {
let base = Url::parse("https://example.com/page/1").unwrap();
let result = generate_page_url(&base, 5);
assert!(result.is_some());
assert!(result.unwrap().contains("page/5"));
}
#[test]
fn test_generate_page_url_append_query() {
let base = Url::parse("https://example.com/search?q=rust").unwrap();
let result = generate_page_url(&base, 2).unwrap();
assert!(result.contains("page=2"));
}
#[test]
fn test_pagination_has_pagination_false() {
let pag = Pagination::none();
assert!(!pag.has_pagination());
}
#[test]
fn test_pagination_has_pagination_with_page_urls() {
let mut pag = Pagination::none();
pag.page_urls.push(PageUrl::new("?page=2", 2, false));
pag.page_urls.push(PageUrl::new("?page=3", 3, false));
assert!(pag.has_pagination());
}
#[test]
fn test_pagination_numbered_pages() {
let mut pag = Pagination::none();
pag.next_url = Some("?page=4".to_string());
pag.prev_url = Some("?page=2".to_string());
pag.page_urls = vec![
PageUrl::new("?page=2", 2, false),
PageUrl::new("?page=3", 3, true),
PageUrl::new("?page=4", 4, false),
];
let numbered = pag.numbered_pages();
assert_eq!(numbered.len(), 1);
assert_eq!(numbered[0].page_number, 3);
}
#[test]
fn test_pagination_none_is_default() {
let pag = Pagination::default();
assert_eq!(pag.pagination_type, PaginationType::None);
assert_eq!(pag.current_page, 1);
assert!(pag.page_urls.is_empty());
}
#[test]
fn test_extract_page_number_from_url_empty() {
assert_eq!(extract_page_number_from_url(""), None);
}
#[test]
fn test_extract_page_links_empty_html() {
let document = Html::parse_document("<html></html>");
let links = extract_page_links(&document, None);
assert!(links.is_empty());
}
#[test]
fn test_determine_pagination_type_empty() {
let urls = vec![];
let document = Html::parse_document("<html></html>");
let ptype = determine_pagination_type(&urls, false, false, &document);
assert_eq!(ptype, PaginationType::None);
}
#[test]
fn test_resolve_url_http_scheme() {
let base = Url::parse("http://example.com").ok();
let result = resolve_url("//cdn.example.com/file.js", base.as_ref());
assert_eq!(result.as_deref(), Some("http://cdn.example.com/file.js"));
}
#[test]
fn test_extract_page_number_ampersand_param() {
assert_eq!(
extract_page_number_from_url("https://example.com?cat=news&page=4"),
Some(4)
);
}
#[test]
fn test_detect_load_more_multiple_classes() {
let html = r#"<html><body>
<div class="show-more">Load More</div>
<div class="load-more">加载更多</div>
</body></html>"#;
let document = Html::parse_document(html);
assert!(detect_load_more(&document));
}
#[test]
fn test_extract_rel_links_with_base_resolution() {
let html = r#"<html><head>
<link rel="next" href="/page/2">
</head></html>"#;
let base = Url::parse("https://example.com/news/").ok();
let document = Html::parse_document(html);
let pag = extract_rel_links(&document, base.as_ref());
assert_eq!(
pag.next_url.as_deref(),
Some("https://example.com/page/2")
);
}
#[test]
fn test_extract_page_links_text_number_fallback() {
let html = r#"<html><body>
<div class="pagination">
<a href="/news?page=abc">3</a>
</div>
</body></html>"#;
let document = Html::parse_document(html);
let links = extract_page_links(&document, None);
assert_eq!(links.len(), 1);
assert_eq!(links[0].page_number, 3);
}
#[test]
fn test_page_url_new() {
let p = PageUrl::new("https://example.com?page=5", 5, true);
assert_eq!(p.url, "https://example.com?page=5");
assert_eq!(p.page_number, 5);
assert!(p.is_current);
}
#[test]
fn test_get_next_page_chinese_text() {
let html = r#"<html><body>
<div class="pagination">
<a href="?page=2" class="next">下一页</a>
</div>
</body></html>"#;
let document = Html::parse_document(html);
let next = get_next_page(&document, None);
assert!(next.is_some());
}
#[test]
fn test_get_prev_page_chinese_text() {
let html = r#"<html><body>
<div class="pagination">
<a href="?page=1" class="prev">上一页</a>
</div>
</body></html>"#;
let document = Html::parse_document(html);
let prev = get_prev_page(&document, None);
assert!(prev.is_some());
}
#[test]
fn test_pagination_type_serialization() {
assert_eq!(
serde_json::to_string(&PaginationType::InfiniteScroll).unwrap(),
"\"infinite_scroll\""
);
assert_eq!(
serde_json::to_string(&PaginationType::LoadMore).unwrap(),
"\"load_more\""
);
assert_eq!(
serde_json::to_string(&PaginationType::Numbered).unwrap(),
"\"numbered\""
);
}
}