""" Chapter link discovery: handles paginated lists, AJAX, JSON-in-scripts, infinite scroll, etc. """ from __future__ import annotations import json import logging import re from dataclasses import dataclass from typing import Callable, Optional from urllib.parse import urljoin, urlparse, urlencode, parse_qs, urlunparse from bs4 import BeautifulSoup from .engine import Chapter, smart_fetch, FetchMode, _is_cloudflare import aiohttp logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Chapter sorting helpers # --------------------------------------------------------------------------- _CH_NUM_RE = re.compile( r"(?:chapter|ch\.?|ep\.?|episode|part|vol\.?|volume)?\s*" r"(\d+(?:\.\d+)?)", re.IGNORECASE, ) _SIDE_STORY_RE = re.compile(r"side\s*story|extra|bonus|omake|special|ss\s*\d", re.IGNORECASE) def _chapter_sort_key(ch: "Chapter") -> tuple[float, float, str]: """Return (is_side_story, number, title) for natural sorting.""" is_side = 1.0 if _SIDE_STORY_RE.search(ch.title) else 0.0 m = _CH_NUM_RE.search(ch.title) num = float(m.group(1)) if m else 0.0 return (is_side, num, ch.title.lower()) def sort_chapters(chapters: list[Chapter]) -> list[Chapter]: sorted_chs = sorted(chapters, key=_chapter_sort_key) for i, ch in enumerate(sorted_chs, start=1): ch.index = i return sorted_chs # --------------------------------------------------------------------------- # Link scoring helpers # --------------------------------------------------------------------------- _CHAPTER_URL_PATTERNS = [ re.compile(p, re.IGNORECASE) for p in [ r"/chapter[-_]?\d", r"/ch[-_]?\d", r"/episode[-_]?\d", r"/ep[-_]?\d", r"/chapter/", r"/chapters/\d", r"/\d+/?$", r"chapter=\d", r"ep=\d", ] ] _NAV_CLASSES = re.compile( r"nav|navigation|menu|header|footer|sidebar|widget|ad|comment|related|recommend|share|social", re.IGNORECASE, ) _CHAPTER_CLASSES = re.compile( r"chapter|episode|listing|list|toc|content|volume|chapters|chap-list|ep-list", re.IGNORECASE, ) def _score_link(a_tag, base_url: str) -> float: """Score an tag on likelihood of being a chapter link.""" href = a_tag.get("href", "") text = a_tag.get_text(strip=True) if not href or not text: return 0.0 score = 0.0 # URL pattern match for pat in _CHAPTER_URL_PATTERNS: if pat.search(href): score += 3.0 break # Text contains number if re.search(r"\d+", text): score += 2.0 # Text contains "chapter"/"episode"/etc if re.search(r"chapter|episode|ep\.|ch\.", text, re.IGNORECASE): score += 2.0 # Parent container looks like a chapter list for parent in a_tag.parents: cls = " ".join(parent.get("class", [])) id_ = parent.get("id", "") if _CHAPTER_CLASSES.search(cls + " " + id_): score += 2.0 break if _NAV_CLASSES.search(cls + " " + id_): score -= 2.0 break # Same domain parsed_base = urlparse(base_url) parsed_href = urlparse(urljoin(base_url, href)) if parsed_href.netloc and parsed_href.netloc != parsed_base.netloc: score -= 5.0 return score # --------------------------------------------------------------------------- # JSON-in-script extraction # --------------------------------------------------------------------------- def _extract_urls_from_scripts(soup: BeautifulSoup, base_url: str) -> list[str]: """Pull chapter URLs from inline JSON blobs in