Spaces:
Sleeping
Sleeping
Download scraper/discovery.py from Krixter/application: direct link, hf CLI and curl.
- Browser
- Download file 12.1 kB
-
https://huggingface.co/spaces/Krixter/application/resolve/main/scraper/discovery.py
- Command line
-
hf download hf://spaces/Krixter/application/scraper/discovery.py
-
curl -L -o discovery.py https://huggingface.co/spaces/Krixter/application/resolve/main/scraper/discovery.py
12.1 kB
| """ | |
| Chapter link discovery — inspired by v1's direct approach: | |
| fetch the index page, find the container holding the most same-domain | |
| links, treat those as the chapter list. Pagination handled if needed. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import re | |
| from typing import Callable, Optional | |
| from urllib.parse import urljoin, urlparse, parse_qs, urlunparse | |
| from bs4 import BeautifulSoup | |
| from .engine import Chapter, smart_fetch, FetchMode, _is_cloudflare | |
| import aiohttp | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # URLs / text that are never chapter links | |
| # --------------------------------------------------------------------------- | |
| _BAD_URL_FRAGMENTS = [ | |
| "/tag/", "/category/", "/author/", "/search", "/login", "/register", | |
| "/contact", "/about", "/privacy", "/sitemap", "/feed", "/cdn-cgi/", | |
| "javascript:", "mailto:", "#", "?s=", "/wp-", "/page/1", | |
| ] | |
| _BAD_TEXT_PATTERNS = re.compile( | |
| r"^(home|about|contact|privacy|terms|login|register|sign.?in|sign.?up|" | |
| r"search|tags?|categor|author|admin|next|prev|previous|back|forward|" | |
| r"more|see more|load more|show more|menu|nav)$", | |
| re.IGNORECASE, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Chapter number detector — used for sorting and validation | |
| # --------------------------------------------------------------------------- | |
| _CH_NUM_RE = re.compile( | |
| r"(?:chapter|ch\.?|ep\.?|episode|part|vol\.?|volume)?\s*(\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]: | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| # Core helper: extract all plausible chapter links from a page | |
| # --------------------------------------------------------------------------- | |
| def _is_bad_url(href: str) -> bool: | |
| low = href.lower() | |
| return any(frag in low for frag in _BAD_URL_FRAGMENTS) | |
| def _is_bad_text(text: str) -> bool: | |
| text = text.strip() | |
| if not text or len(text) > 200: | |
| return True | |
| return bool(_BAD_TEXT_PATTERNS.match(text)) | |
| def _extract_links_from_html(html: str, base_url: str) -> list[tuple[str, str]]: | |
| """ | |
| V1-inspired approach: parse the page, collect every same-domain <a> link | |
| that isn't obviously navigation/noise. Return [(url, title), ...] in | |
| document order, deduplicated. | |
| """ | |
| base_domain = urlparse(base_url).netloc | |
| soup = BeautifulSoup(html, "lxml") | |
| # Strip script/style/nav noise up front | |
| for tag in soup.find_all(["script", "style", "noscript", "iframe", | |
| "header", "footer", "nav"]): | |
| tag.decompose() | |
| seen: dict[str, str] = {} | |
| for a in soup.find_all("a", href=True): | |
| href = a.get("href", "").strip() | |
| if not href: | |
| continue | |
| full_url = urljoin(base_url, href) | |
| parsed = urlparse(full_url) | |
| # Same domain only | |
| if parsed.netloc and parsed.netloc != base_domain: | |
| continue | |
| if _is_bad_url(full_url): | |
| continue | |
| title = a.get_text(strip=True) | |
| if _is_bad_text(title): | |
| continue | |
| if full_url not in seen: | |
| seen[full_url] = title | |
| return list(seen.items()) | |
| # --------------------------------------------------------------------------- | |
| # Container-based strategy (v1's key insight) | |
| # | |
| # Rather than scoring individual links, find the HTML container that holds | |
| # the MOST links to the same domain. That container is almost always the | |
| # chapter list. This matches exactly how v1 worked for novelhall — it just | |
| # happened to know the specific container ID. Here we discover it | |
| # automatically by counting links per container. | |
| # --------------------------------------------------------------------------- | |
| def _find_chapter_list_container(html: str, base_url: str) -> list[tuple[str, str]]: | |
| """ | |
| Find the single container element with the most same-domain links. | |
| Returns [(url, title), ...] from that container. | |
| """ | |
| base_domain = urlparse(base_url).netloc | |
| soup = BeautifulSoup(html, "lxml") | |
| for tag in soup.find_all(["script", "style", "noscript", "iframe", | |
| "header", "footer", "nav"]): | |
| tag.decompose() | |
| best_container = None | |
| best_count = 0 | |
| # Walk all block-level containers, find the one with most valid links | |
| for el in soup.find_all(["div", "ul", "ol", "section", "article", "main", "table"]): | |
| links = [] | |
| for a in el.find_all("a", href=True, recursive=True): | |
| href = a.get("href", "").strip() | |
| if not href: | |
| continue | |
| full_url = urljoin(base_url, href) | |
| parsed = urlparse(full_url) | |
| if parsed.netloc and parsed.netloc != base_domain: | |
| continue | |
| if _is_bad_url(full_url): | |
| continue | |
| title = a.get_text(strip=True) | |
| if _is_bad_text(title): | |
| continue | |
| links.append((full_url, title)) | |
| # Prefer the container with the most unique links | |
| unique = len(dict(links)) | |
| if unique > best_count: | |
| best_count = unique | |
| best_container = links | |
| if best_container and best_count >= 3: | |
| # Deduplicate preserving order | |
| seen: dict[str, str] = {} | |
| for url, title in best_container: | |
| if url not in seen: | |
| seen[url] = title | |
| return list(seen.items()) | |
| return [] | |
| # --------------------------------------------------------------------------- | |
| # Pagination | |
| # --------------------------------------------------------------------------- | |
| def _next_page_url(html: str, current_url: str) -> str | None: | |
| """Find a 'next page' link for paginated chapter lists.""" | |
| soup = BeautifulSoup(html, "lxml") | |
| base_domain = urlparse(current_url).netloc | |
| # Look for explicit next-page links | |
| for a in soup.find_all("a", href=True): | |
| text = a.get_text(strip=True).lower() | |
| rel = a.get("rel", []) | |
| if text in ("next", "next page", "›", "»", ">") or "next" in rel: | |
| href = urljoin(current_url, a["href"]) | |
| if urlparse(href).netloc == base_domain and href != current_url: | |
| return href | |
| return None | |
| def _build_page_urls(base_url: str, max_pages: int = 50) -> list[str]: | |
| """Generate ?page=N and /page/N variants.""" | |
| parsed = urlparse(base_url) | |
| candidates = [] | |
| for i in range(2, max_pages + 1): | |
| qs = parse_qs(parsed.query) | |
| qs["page"] = [str(i)] | |
| params = "&".join(f"{k}={v[0]}" for k, v in qs.items()) | |
| candidates.append(urlunparse(parsed._replace(query=params))) | |
| path_paged = parsed.path.rstrip("/") + f"/page/{i}" | |
| candidates.append(urlunparse(parsed._replace(path=path_paged, query=""))) | |
| return candidates | |
| # --------------------------------------------------------------------------- | |
| # TOC URL variants to try | |
| # --------------------------------------------------------------------------- | |
| _TOC_SUFFIXES = [ | |
| "", | |
| "/chapter-list", | |
| "/chapters", | |
| "/chapter", | |
| "/contents", | |
| "/toc", | |
| "/episodes", | |
| "/all-chapters", | |
| "/table-of-contents", | |
| ] | |
| def _toc_candidates(url: str) -> list[str]: | |
| parsed = urlparse(url) | |
| base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}" | |
| return [base + s for s in _TOC_SUFFIXES] | |
| # --------------------------------------------------------------------------- | |
| # Main discover function | |
| # --------------------------------------------------------------------------- | |
| async def discover_chapters( | |
| session: aiohttp.ClientSession, | |
| novel_url: str, | |
| mode: FetchMode = FetchMode.BALANCED, | |
| cookies: dict | None = None, | |
| log_cb: Callable[[str], None] | None = None, | |
| ) -> list[Chapter]: | |
| """ | |
| Discover all chapter links. Uses v1's core insight: find the page | |
| container with the most links — that's the chapter list. | |
| """ | |
| def log(msg: str): | |
| if log_cb: | |
| log_cb(msg) | |
| logger.info(msg) | |
| async def fetch(url: str) -> str | None: | |
| try: | |
| html, status = await smart_fetch( | |
| session, url, mode=mode, cookies=cookies, log_cb=log_cb | |
| ) | |
| if _is_cloudflare(html, status): | |
| log(f"🛑 Cloudflare wall at {url}") | |
| return None | |
| return html | |
| except Exception as exc: | |
| log(f"⚠️ Fetch failed for {url}: {exc}") | |
| return None | |
| log(f"🔍 Discovering chapters for: {novel_url}") | |
| all_links: dict[str, str] = {} # url -> title | |
| # ── Step 1: Try TOC URL variants, use the one with most chapter links ── | |
| best_html = None | |
| best_url = None | |
| best_count = 0 | |
| for toc_url in _toc_candidates(novel_url): | |
| html = await fetch(toc_url) | |
| if not html: | |
| continue | |
| links = _find_chapter_list_container(html, toc_url) | |
| log(f" {toc_url} → {len(links)} links") | |
| if len(links) > best_count: | |
| best_count = len(links) | |
| best_html = html | |
| best_url = toc_url | |
| # If the base URL already has plenty, stop trying variants | |
| if toc_url == _toc_candidates(novel_url)[0] and best_count >= 10: | |
| break | |
| if best_html and best_count >= 3: | |
| log(f"✅ Best TOC page: {best_url} ({best_count} links)") | |
| for url, title in _find_chapter_list_container(best_html, best_url): | |
| if url not in all_links: | |
| all_links[url] = title | |
| # ── Step 2: Follow pagination if the list seems incomplete ──────────── | |
| if best_html and best_url: | |
| current_html = best_html | |
| current_url = best_url | |
| page = 2 | |
| while page <= 100: | |
| # Try explicit next-page link first | |
| next_url = _next_page_url(current_html, current_url) | |
| if not next_url: | |
| # Try ?page=N pattern | |
| parsed = urlparse(current_url) | |
| qs = parse_qs(parsed.query) | |
| qs["page"] = [str(page)] | |
| params = "&".join(f"{k}={v[0]}" for k, v in qs.items()) | |
| next_url = urlunparse(parsed._replace(query=params)) | |
| html = await fetch(next_url) | |
| if not html: | |
| break | |
| new_links = _find_chapter_list_container(html, next_url) | |
| before = len(all_links) | |
| for url, title in new_links: | |
| if url not in all_links: | |
| all_links[url] = title | |
| added = len(all_links) - before | |
| if added == 0: | |
| break # No new links on this page — stop paginating | |
| log(f" Page {page}: +{added} new links ({len(all_links)} total)") | |
| current_html = html | |
| current_url = next_url | |
| page += 1 | |
| if not all_links: | |
| log("⚠️ Container method found nothing — falling back to flat link scan") | |
| html = await fetch(novel_url) | |
| if html: | |
| for url, title in _extract_links_from_html(html, novel_url): | |
| all_links[url] = title | |
| log(f"✅ Discovered {len(all_links)} chapter links total") | |
| chapters: list[Chapter] = [] | |
| for i, (href, title) in enumerate(all_links.items(), start=1): | |
| if not title: | |
| title = f"Chapter {i}" | |
| chapters.append(Chapter(index=i, title=title, url=href)) | |
| return sort_chapters(chapters) | |